commit d83225d7368493a4ca1acb1730bf89ffe138d267 Author: admin <362324317@qq.com> Date: Fri May 15 05:50:50 2026 +0800 chore: initial commit - CloudSearch v0.0.2 diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..da44a59 --- /dev/null +++ b/.env.example @@ -0,0 +1,51 @@ +# ============================================================ +# CloudSearch 环境变量配置示例 (v2.1 优化版) +# 复制此文件为 .env 并修改对应值 +# ============================================================ + +# --- 必需:管理员账号(请务必修改默认密码)--- +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-to-a-strong-password + +# --- 必需:JWT 密钥(使用随机值,勿用默认值)--- +# 生成命令: openssl rand -hex 32 +JWT_SECRET=change-me-to-a-random-64-char-hex-string + +# --- 推荐:Cookie 加密密钥(保护网盘凭证)--- +# 生成命令: openssl rand -hex 32 +# 不设置则每次重启生成临时密钥(旧数据不可解密) +COOKIE_ENCRYPTION_KEY=change-me-to-a-random-key + +# --- CORS 访问控制(生产环境必须设置真实域名)--- +# 示例: CORS_ORIGIN=https://search.example.com +CORS_ORIGIN=https://your-production-domain.com + +# --- Redis 连接(可选,无 Redis 时自动降级)--- +REDIS_URL=redis://redis:6379 + +# --- PanSou 搜索引擎地址(容器内无需修改)--- +PANSOU_URL=http://pansou:80 +PANSOU_AUTH_TOKEN= + +# --- 视频解析服务地址(容器内无需修改)--- +VIDEO_PARSER_URL=http://video-parser:3001 + +# --- 链接验证配置 --- +VALIDATION_CONCURRENCY=10 +VALIDATION_TIMEOUT_MS=5000 +VALIDATION_CACHE_TTL_VALID=600 +VALIDATION_CACHE_TTL_INVALID=1800 + +# --- 日志级别 (debug|info|warn|error) --- +LOG_LEVEL=info + +# --- 可选:TMDB API Token(用于影视元数据)--- +# 注册: https://www.themoviedb.org/settings/api +# TMDB_API_TOKEN= + +# --- 可选:代理设置 --- +# HTTP_PROXY=http://proxy:8080 +# HTTPS_PROXY=http://proxy:8080 + +# --- 站点访问地址(用于 CORS 和 SEO)--- +SITE_URL=https://your-production-domain.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf00eca --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +node_modules/ +dist/ +.env +.h5/ +*.log +.DS_Store +.vite/ +*.bak +*.bak2 +*.bak3 +*.tar.gz +*.tar +deploy.sh +backup.sh +check.sh +icons/ +scripts/ +Dockerfile +Dockerfile.bak +.dockerignore +docker-compose.yml +docker-compose.yml.bak +pansou-web-latest.tar.gz +video-parser-latest.tar.gz +cloudsearch-app-v2.0.26.tar.gz +uploads/ diff --git a/README.md b/README.md new file mode 100755 index 0000000..b042da9 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# CloudSearch — 网盘搜索 + 视频解析一站式平台 + +## 快速部署 + +```bash +# 1. 解压 +tar xzf cloudsearch-deploy.tar.gz +cd cloudsearch + +# 2. 配置环境变量 +cp .env.example .env +# 自动生成 JWT Secret +sed -i "s/change-me-to-a-random-64-char-hex-string/$(openssl rand -hex 32)/" .env +# 自动生成 Cookie 加密密钥 +sed -i "s/change-me-to-a-random-key/$(openssl rand -hex 32)/" .env +# ⚠️ 务必手动修改 ADMIN_PASSWORD 和 CORS_ORIGIN +echo "⚠️ 请编辑 .env 文件,修改 ADMIN_PASSWORD 和 CORS_ORIGIN" + +# 3. 一键启动 +docker compose up -d + +# 4. 访问 +# 首页: http://服务器IP +# 管理后台: http://服务器IP/admin/login +``` + +## 首次使用 + +1. 登录管理后台 `/admin/login`(账号: admin,密码: 你在 .env 中设置的) +2. 在「网盘配置」中添加夸克网盘 Cookie +3. 在「推广管理」中添加首页推广内容(可选) +4. 返回首页即可开始搜索 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..05f601d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4147 @@ +{ + "name": "cloudsearch-backend", + "version": "2.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudsearch-backend", + "version": "2.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^8.0.0", + "https-proxy-agent": "^9.0.0", + "ioredis": "^5.4.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "playwright": "^1.52.0", + "sharp": "^0.33.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "dev": true, + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "engines": { + "node": ">= 20" + } + }, + "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==" + }, + "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==" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "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" + } + ] + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/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==" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "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, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "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==", + "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==", + "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==", + "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.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "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.15.1", + "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/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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==" + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=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==", + "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==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "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==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.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==", + "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==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "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==" + }, + "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==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "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==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "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==", + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "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==", + "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==", + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "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==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "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==" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "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.15.1", + "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-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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==" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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==", + "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==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "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==", + "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==", + "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==", + "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==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "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/https-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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==" + }, + "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==", + "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" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/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==" + }, + "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==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "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==", + "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==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "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==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "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==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==" + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "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-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==" + }, + "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==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "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==", + "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": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "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/readable-stream/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==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "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" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "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==", + "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==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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==" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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==", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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==", + "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==", + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "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" + } + ] + }, + "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" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "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/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/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==" + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "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==", + "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/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==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.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==", + "engines": { + "node": ">=0.6" + } + }, + "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==", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "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==", + "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==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "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==" + }, + "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==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/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 + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite/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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/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 + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..f73f8ae --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "cloudsearch-backend", + "version": "2.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc", + "start": "node dist/main.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^8.0.0", + "https-proxy-agent": "^9.0.0", + "ioredis": "^5.4.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "playwright": "^1.52.0", + "sharp": "^0.33.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/packages/backend/package-lock.json b/packages/backend/package-lock.json new file mode 100755 index 0000000..9082039 --- /dev/null +++ b/packages/backend/package-lock.json @@ -0,0 +1,4200 @@ +{ + "name": "cloudsearch-backend", + "version": "2.0.9", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudsearch-backend", + "version": "2.0.9", + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^8.0.0", + "https-proxy-agent": "^9.0.0", + "ioredis": "^5.4.0", + "jsonwebtoken": "^9.0.2", + "jsqr": "^1.4.0", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "playwright": "^1.52.0", + "sharp": "^0.33.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "engines": { + "node": ">= 20" + } + }, + "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==" + }, + "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==" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "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" + } + ] + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/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==" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "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, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "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==", + "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==", + "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==", + "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.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "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.15.1", + "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/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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==" + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=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==", + "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==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.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==", + "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==", + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "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==" + }, + "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==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "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==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "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==", + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "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==", + "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==", + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "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==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "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==" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "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-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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==" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "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==", + "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==" + }, + "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, + "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==", + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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==" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "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/https-proxy-agent": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.0.0.tgz", + "integrity": "sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/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==" + }, + "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==", + "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" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/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==" + }, + "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==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "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==", + "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==" + }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "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==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, + "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==" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==" + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "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-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==" + }, + "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==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "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==", + "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": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "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/readable-stream/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==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "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" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "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==", + "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==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "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==" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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==", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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==", + "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==", + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "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" + } + ] + }, + "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" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "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/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==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/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==" + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "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==", + "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/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==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.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==", + "engines": { + "node": ">=0.6" + } + }, + "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", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "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==", + "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==", + "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==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "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==" + }, + "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==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/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 + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/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 + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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==" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100755 index 0000000..e69bae8 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "cloudsearch-backend", + "version": "0.0.2", + "private": true, + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc", + "start": "node dist/main.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "express-rate-limit": "^7.4.0", + "helmet": "^8.0.0", + "https-proxy-agent": "^9.0.0", + "socks-proxy-agent": "^9.0.0", + "ioredis": "^5.4.0", + "jsqr": "^1.4.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "playwright": "^1.52.0", + "sharp": "^0.33.0", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/better-sqlite3": "^7.6.11", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.12", + "@types/uuid": "^10.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} \ No newline at end of file diff --git a/packages/backend/src/admin/auth.service.ts b/packages/backend/src/admin/auth.service.ts new file mode 100755 index 0000000..10a164d --- /dev/null +++ b/packages/backend/src/admin/auth.service.ts @@ -0,0 +1,76 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import { Request, Response, NextFunction } from 'express'; +import config from '../config'; +import { getDb } from '../database/database'; + +export interface AuthPayload { + id: number; + username: string; +} + +export function login(username: string, password: string): string | null { + const db = getDb(); + const row = db.prepare('SELECT id, username, password_hash FROM admins WHERE username = ?').get(username) as any; + + if (!row) return null; + + const valid = bcrypt.compareSync(password, row.password_hash); + if (!valid) return null; + + // Update last login + db.prepare('UPDATE admins SET last_login = datetime(\'now\') WHERE id = ?').run(row.id); + + // Generate JWT + const payload: AuthPayload = { id: row.id, username: row.username }; + const token = jwt.sign(payload, config.jwtSecret, { expiresIn: '24h' }); + + return token; +} + +export function verifyToken(token: string): AuthPayload | null { + try { + const decoded = jwt.verify(token, config.jwtSecret) as AuthPayload; + return decoded; + } catch { + return null; + } +} + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Missing or invalid authorization header', code: 401 }); + return; + } + + const token = authHeader.split(' ')[1]; + const payload = verifyToken(token); + + if (!payload) { + res.status(401).json({ error: 'Invalid or expired token', code: 401 }); + return; + } + + (req as any).user = payload; + next(); +} + +export function changePassword(username: string, oldPassword: string, newPassword: string): { success: boolean; message: string } { + const db = getDb(); + const row = db.prepare('SELECT id, password_hash FROM admins WHERE username = ?').get(username) as any; + if (!row) { + return { success: false, message: '用户不存在' }; + } + + const valid = bcrypt.compareSync(oldPassword, row.password_hash); + if (!valid) { + return { success: false, message: '原密码错误' }; + } + + const salt = bcrypt.genSaltSync(10); + const hash = bcrypt.hashSync(newPassword, salt); + db.prepare("UPDATE admins SET password_hash = ? WHERE id = ?").run(hash, row.id); + return { success: true, message: '密码修改成功' }; +} diff --git a/packages/backend/src/admin/stats.service.ts b/packages/backend/src/admin/stats.service.ts new file mode 100755 index 0000000..3412e97 --- /dev/null +++ b/packages/backend/src/admin/stats.service.ts @@ -0,0 +1,161 @@ +import { getDb } from '../database/database'; +import { formatLocalDate } from '../utils/time'; + +export interface AdminStats { + todaySearches: number; + todaySaves: number; + monthSearches: number; + monthSaves: number; + totalSearches: number; + totalSaves: number; + hotKeywords: Array<{ keyword: string; count: number }>; + trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }>; + cloudUsage: Array<{ + cloudType: string; + nickname: string; + storageUsed: string; + storageTotal: string; + isActive: boolean; + }>; + topIps: Array<{ ip: string; ip_location: string | null; count: number }>; + provinceRankings: Array<{ province: string; count: number }>; +} + +/** + * Get today's date string in the configured timezone (e.g. "2026-05-04"). + * Delegates to shared formatLocalDate() in utils/time.ts. + */ +function todayLocalDate(): string { + return formatLocalDate(); +} + +/** + * Get the first day of the current month in the configured timezone. + */ +function monthStartLocalDate(): string { + return todayLocalDate().slice(0, 7) + '-01'; +} + +export function getStats(trendDays: number = 7): AdminStats { + const db = getDb(); + + // Use local timezone date — NOT UTC via toISOString() + const today = todayLocalDate(); + const monthStart = monthStartLocalDate(); + + // IMPORTANT: created_at is stored as "YYYY-MM-DDTHH:mm:ss+08:00" (localTimestamp) + // SQLite's date() function would interpret the +08:00 timezone offset and + // convert to UTC, giving wrong date. Instead, use SUBSTR to get first 10 chars. + const todaySearchesRow = db.prepare( + "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?" + ).get(today) as any; + + const todaySavesRow = db.prepare( + "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?" + ).get(today) as any; + + const monthSearchesRow = db.prepare( + "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) >= ?" + ).get(monthStart) as any; + + const monthSavesRow = db.prepare( + "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) >= ?" + ).get(monthStart) as any; + + // Total searches + const totalSearchesRow = db.prepare( + "SELECT COUNT(*) as count FROM search_stats" + ).get() as any; + + // Total saves + const totalSavesRow = db.prepare( + "SELECT COUNT(*) as count FROM save_records" + ).get() as any; + + // Hot keywords + const hotKeywords = db.prepare( + 'SELECT keyword, search_count as count FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + ).all() as Array<{ keyword: string; count: number }>; + + // Trend data (configurable days, default 7) + const trendLen = Math.min(Math.max(trendDays, 1), 90); + const trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }> = []; + for (let i = trendLen - 1; i >= 0; i--) { + const d = new Date(); + const target = new Date(d.getTime() - i * 86400000); + const dateStr = formatLocalDate(target); + const searchRow = db.prepare( + "SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?" + ).get(dateStr) as any; + const saveRow = db.prepare( + "SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?" + ).get(dateStr) as any; + trendTrend.push({ + date: dateStr, + searches: searchRow?.count || 0, + saves: saveRow?.count || 0, + searchDelta: 0, + saveDelta: 0, + }); + } + // Compute day-over-day delta (absolute change from previous day) + for (let i = trendTrend.length - 1; i > 0; i--) { + const prev = trendTrend[i - 1]; + const curr = trendTrend[i]; + curr.searchDelta = curr.searches - prev.searches; + curr.saveDelta = curr.saves - prev.saves; + } + + // Cloud usage + const cloudUsage = db.prepare( + 'SELECT cloud_type as cloudType, nickname, storage_used as storageUsed, storage_total as storageTotal, is_active as isActive FROM cloud_configs ORDER BY id ASC' + ).all() as Array<{ + cloudType: string; + nickname: string; + storageUsed: string; + storageTotal: string; + isActive: boolean; + }>; + + // Top IPs from save_records — correctly count total per IP, then get latest location + const topIps = db.prepare( + `SELECT ip_address as ip, COUNT(*) as count, + (SELECT ip_location FROM save_records s2 + WHERE s2.ip_address = s1.ip_address AND s2.ip_location IS NOT NULL AND s2.ip_location != '' + ORDER BY s2.created_at DESC LIMIT 1) as ip_location + FROM save_records s1 + WHERE ip_address IS NOT NULL AND ip_address != '' + GROUP BY ip_address + ORDER BY count DESC LIMIT 10` + ).all() as Array<{ ip: string; ip_location: string | null; count: number }>; + + // Province rankings — extract province from ip_location (first segment) + let provinceRankings: Array<{ province: string; count: number }> = []; + const locRows = db.prepare( + `SELECT ip_location FROM save_records WHERE ip_location IS NOT NULL AND ip_location != ''` + ).all() as Array<{ ip_location: string }>; + const provMap = new Map(); + for (const row of locRows) { + const parts = row.ip_location.trim().split(/\s+/); + const province = parts[0] || '未知'; + provMap.set(province, (provMap.get(province) || 0) + 1); + } + provinceRankings = Array.from(provMap.entries()) + .map(([province, count]) => ({ province, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 15); + + return { + todaySearches: (todaySearchesRow as any)?.count || 0, + todaySaves: (todaySavesRow as any)?.count || 0, + monthSearches: (monthSearchesRow as any)?.count || 0, + monthSaves: (monthSavesRow as any)?.count || 0, + totalSearches: (totalSearchesRow as any)?.count || 0, + totalSaves: (totalSavesRow as any)?.count || 0, + hotKeywords, + trendTrend, + cloudUsage, + topIps, + provinceRankings, + }; +} diff --git a/packages/backend/src/admin/system-config.service.ts b/packages/backend/src/admin/system-config.service.ts new file mode 100755 index 0000000..6ee09ee --- /dev/null +++ b/packages/backend/src/admin/system-config.service.ts @@ -0,0 +1,40 @@ +import { getDb } from '../database/database'; +import { localTimestamp } from '../utils/time'; + +export interface SystemConfigEntry { + key: string; + value: string; + description?: string; + updated_at?: string; +} + +export function getAllSystemConfigs(): SystemConfigEntry[] { + const db = getDb(); + return db.prepare('SELECT key, value, description, updated_at FROM system_configs ORDER BY key').all() as SystemConfigEntry[]; +} + +export function getSystemConfig(key: string): string | null { + const db = getDb(); + const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get(key) as { value: string } | undefined; + return row?.value ?? null; +} + +export function updateSystemConfig(key: string, value: string): void { + const db = getDb(); + db.prepare( + "UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?" + ).run(value, localTimestamp(), key); +} + +export function updateSystemConfigs(entries: { key: string; value: string }[]): void { + const db = getDb(); + const update = db.prepare( + "UPDATE system_configs SET value = ?, updated_at = ? WHERE key = ?" + ); + const tx = db.transaction((items: { key: string; value: string }[]) => { + for (const item of items) { + update.run(item.value, localTimestamp(), item.key); + } + }); + tx(entries); +} diff --git a/packages/backend/src/cloud/admin.routes.ts b/packages/backend/src/cloud/admin.routes.ts new file mode 100644 index 0000000..b048162 --- /dev/null +++ b/packages/backend/src/cloud/admin.routes.ts @@ -0,0 +1,615 @@ +import { Router, Request, Response } from 'express'; +// Native fetch available in Node 20+ +import fs from "fs"; +import { execSync } from 'child_process'; +import { adminLimiter, loginLimiter } from '../middleware/rate-limit'; +import { getSaveRecords } from '../cloud/cloud.service'; +import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service'; +// Note: check-in routes were removed (sign-in feature removed) +import { getAllCloudTypes } from '../cloud/cloud-types.service'; +import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; +import { getStats } from '../admin/stats.service'; +import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; +import { testProxyConnection } from '../utils/proxy-agent'; +import { getDb } from '../database/database'; +import { reconnectRedis, testRedisConnection } from '../middleware/cache'; +import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; +import { BaiduDriver } from '../cloud/drivers/baidu.driver'; + +const router = Router(); + +// ═══════════════════════════════════════ +// Public routes (no auth required) +// ═══════════════════════════════════════ + +/** + * POST /api/admin/login + * Admin login + */ +router.post('/admin/login', loginLimiter, (req: Request, res: Response) => { + try { + const { username, password } = req.body; + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + const token = login(username, password); + if (!token) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + res.json({ token }); + } catch (err: any) { + console.error('[Login] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/admin/cloud-types + * List all cloud types (public, read-only). + */ +router.get('/admin/cloud-types', (_req: Request, res: Response) => { + try { + const types = getAllCloudTypes(); + res.json({ types }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +// ═══════════════════════════════════════ +// QR Login routes (no auth — user not logged in yet) +// MUST be before authMiddleware! +// ═══════════════════════════════════════ + +// ===== 夸克扫码登录 ===== +router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => { + try { + const result = await startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result = await getQrLoginStatus(sessionId); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + await cancelQrLogin(sessionId); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ===== 百度扫码登录 ===== +router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => { + try { + const result = await BaiduDriver.startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result: any = await BaiduDriver.getQrLoginStatus(sessionId); + // Map to frontend-expected format (frontend reads data.cookie) + res.json({ + ok: true, + status: result.status, + cookie: result.cookie || result.access_token || "", + nickname: result.nickname || "", + storage_used: result.storage_used || "", + storage_total: result.storage_total || "", + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => { + try { + BaiduDriver.cancelQrLogin(req.params.sessionId as string); + } catch {} + res.json({ ok: true }); +}); + +// ═══════════════════════════════════════ +// Auth wall — all routes below require JWT +// ═══════════════════════════════════════ +router.use('/admin', authMiddleware); + +// ═══════════════════════════════════════ +// Cloud Configs CRUD +// ═══════════════════════════════════════ + +/** GET /api/admin/cloud-configs — list all cloud configs */ +router.get('/admin/cloud-configs', (_req: Request, res: Response) => { + try { + const configs = getCloudConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); + } +}); + +/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */ +router.post('/admin/cloud-configs', (req: Request, res: Response) => { + try { + const data = req.body; + if (!data.cloud_type) { + res.status(400).json({ error: 'cloud_type is required' }); + return; + } + // Normalize is_active: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0; + // Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0; + const saved = saveCloudConfig(data); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to save cloud config' }); + } +}); + +/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */ +router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const existing = getCloudConfigById(id); + if (!existing) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + const saved = saveCloudConfig({ ...req.body, id }); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update cloud config' }); + } +}); + +/** DELETE /api/admin/cloud-configs/:id */ +router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const ok = deleteCloudConfig(id); + if (!ok) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to delete cloud config' }); + } +}); + +/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */ +router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => { + try { + const type = req.params.type as string; + const { cookie, id } = req.body; + + // If cookie is provided directly, test with it (for new configs not yet saved) + if (cookie) { + const result = await testCloudConnectionWithCookie(type, cookie); + res.json(result); + return; + } + + // Otherwise test by config id + if (id) { + const result = await testCloudConnection(parseInt(id)); + res.json(result); + return; + } + + res.status(400).json({ success: false, message: 'Provide either cookie or id' }); + } catch (err: any) { + res.status(500).json({ success: false, message: err.message || 'Connection test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Stats +// ═══════════════════════════════════════ + +/** GET /api/admin/stats */ +router.get('/admin/stats', (req: Request, res: Response) => { + try { + const days = req.query.days ? parseInt(req.query.days as string) : 7; + const stats = getStats(days); + res.json(stats); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get stats' }); + } +}); + +// ═══════════════════════════════════════ +// Save Records (转存日志) +// ═══════════════════════════════════════ + +/** GET /api/admin/save-records */ +router.get('/admin/save-records', (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 20; + const startDate = req.query.startDate as string | undefined; + const endDate = req.query.endDate as string | undefined; + const status = req.query.status as string | undefined; + const sourceType = req.query.sourceType as string | undefined; + const keyword = req.query.keyword as string | undefined; + const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get save records' }); + } +}); + +// ═══════════════════════════════════════ +// System Configs +// ═══════════════════════════════════════ + +/** GET /api/admin/system-configs */ +router.get('/admin/system-configs', (_req: Request, res: Response) => { + try { + const configs = getAllSystemConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get system configs' }); + } +}); + +/** PUT /api/admin/system-configs — batch update */ +router.put('/admin/system-configs', (req: Request, res: Response) => { + try { + const { entries } = req.body; + if (!entries || !Array.isArray(entries)) { + res.status(400).json({ error: 'entries array is required' }); + return; + } + updateSystemConfigs(entries); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update system configs' }); + } +}); + +// ═══════════════════════════════════════ +// Cloud Types Toggle +// ═══════════════════════════════════════ + +/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */ +router.put('/admin/cloud-types', (req: Request, res: Response) => { + try { + const { type, enabled } = req.body; + if (!type) { + res.status(400).json({ error: 'type is required' }); + return; + } + const db = getDb(); + db.prepare( + `INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to toggle cloud type' }); + } +}); + +// ═══════════════════════════════════════ +// Change Password +// ═══════════════════════════════════════ + +/** POST /api/admin/change-password */ +router.post('/admin/change-password', (req: Request, res: Response) => { + try { + const { oldPassword, newPassword } = req.body; + if (!oldPassword || !newPassword) { + res.status(400).json({ error: 'Both old and new passwords are required' }); + return; + } + // Get username from JWT + const authHeader = req.headers.authorization || ''; + const token = authHeader.replace('Bearer ', ''); + const payload = verifyToken(token); + if (!payload) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + const result = changePassword(payload.username, oldPassword, newPassword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to change password' }); + } +}); + +// ═══════════════════════════════════════ +// DB Status +// ═══════════════════════════════════════ + +/** GET /api/admin/db-status */ +router.get('/admin/db-status', async (_req: Request, res: Response) => { + try { + const dbFile = getSystemConfig('db_path') || ''; + let dbSize = 'N/A'; + if (dbFile) { + try { + const stats = fs.statSync(dbFile); + dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB'; + } catch {} + } + + const db = getDb(); + const counts = { + save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0, + search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0, + system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0, + cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0, + content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0, + }; + + // Redis status + let redis_status = 'disconnected'; + let redis_url = getSystemConfig('redis_url') || ''; + try { + const testResult = await testRedisConnection(redis_url); + redis_status = testResult.ok ? 'connected' : 'disconnected'; + } catch { + redis_status = 'error'; + } + + res.json({ + db_size: dbSize, + db_path: dbFile, + ...counts, + redis_status, + redis_url, + }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get DB status' }); + } +}); + +// ═══════════════════════════════════════ +// Test Redis Connection +// ═══════════════════════════════════════ + +/** POST /api/admin/test-redis */ +router.post('/admin/test-redis', async (req: Request, res: Response) => { + try { + const { url } = req.body; + if (!url) { + res.status(400).json({ ok: false, info: 'Redis URL is required' }); + return; + } + const result = await testRedisConnection(url); + res.json(result); + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'Redis test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Test External Service +// ═══════════════════════════════════════ + +/** POST /api/admin/test-external-service */ +router.post('/admin/test-external-service', async (req: Request, res: Response) => { + try { + const { type, url, token } = req.body; + const start = Date.now(); + + switch (type) { + case 'pansou': { + const pansouUrl = url || getSystemConfig('pansou_url') || ''; + if (!pansouUrl) { + res.json({ ok: false, info: 'PanSou URL not configured' }); + return; + } + const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + res.json({ + ok: response.ok && data?.status === 'ok', + latency, + info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败', + }); + break; + } + case 'video_parser': { + const parserUrl = url || getSystemConfig('video_parser_url') || ''; + if (!parserUrl) { + res.json({ ok: false, info: 'Video Parser URL not configured' }); + return; + } + const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'tmdb': { + const tmdbToken = token || getSystemConfig('tmdb_api_key') || ''; + if (!tmdbToken) { + res.json({ ok: false, info: 'TMDB API Key not configured' }); + return; + } + const response = await fetch('https://api.themoviedb.org/3/configuration', { + headers: { Authorization: `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'proxy': { + const proxyUrl = url || getSystemConfig('search_proxy_url') || ''; + if (!proxyUrl) { + res.json({ ok: false, info: 'Proxy URL not configured' }); + return; + } + const result = await testProxyConnection(proxyUrl); + res.json(result); + break; + } + case 'ip_geo': { + const geoUrl = url || getSystemConfig('ip_geo_api_url') || ''; + if (!geoUrl) { + res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' }); + return; + } + const testUrl = geoUrl.replace('{ip}', '8.8.8.8'); + const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + const valid = !!(data?.country || data?.region || data?.city || data?.countryCode); + res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' }); + break; + } + default: + res.json({ ok: false, info: `Unknown service type: ${type}` }); + } + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'External service test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Pansou Info & Update +// ═══════════════════════════════════════ + +/** GET /api/admin/pansou-info — pansou health + version + update check */ +router.get('/admin/pansou-info', async (_req: Request, res: Response) => { + try { + const baseUrl = getSystemConfig('pansou_url') || ''; + if (!baseUrl) { + res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' }); + return; + } + + // Fetch PanSou health + const healthUrl = baseUrl + '/api/health'; + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) }); + const healthData: any = await response.json(); + const channelCount = healthData.channels_count || 0; + const pluginCount = healthData.plugin_count || 0; + + // Derive disk count from channel names + const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease']; + const drives = new Set(); + for (const ch of (healthData.channels || [])) { + for (const kw of driveKeywords) { + if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; } + } + } + const diskCount = drives.size || 5; + + // Get local version from docker label + let version = ''; + let hasUpdate = false; + let latestVersion = ''; + try { + const created = execSync( + `docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`, + { timeout: 5000, encoding: 'utf8' } + ).trim(); + version = created ? created.slice(0, 10) : ''; + + // Check update cache + const cacheFile = '/tmp/pansou-update-cache.json'; + let cache: any = null; + try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {} + const threeDays = 3 * 24 * 3600 * 1000; + + if (!cache || (Date.now() - cache.checkedAt) > threeDays) { + // Check GHCR for latest version + try { + const tokenRes = await fetch( + 'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io' + ); + const ghcrToken = (await tokenRes.json() as any).token; + const manifestRes = await fetch( + 'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest', + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } } + ); + const manifestList: any = await manifestRes.json(); + const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux'); + if (amd64) { + const blobRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`, + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } } + ); + const blobData: any = await blobRes.json(); + const cfgDigest = blobData.config?.digest; + if (cfgDigest) { + const cfgRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`, + { headers: { Authorization: `Bearer ${ghcrToken}` } } + ); + const cfgData: any = await cfgRes.json(); + const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created']; + if (remoteCreated) { + latestVersion = remoteCreated.slice(0, 10); + if (version && latestVersion !== version) hasUpdate = true; + } + } + } + } catch {} + fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion })); + } else { + hasUpdate = cache.hasUpdate; + latestVersion = cache.latestVersion; + } + } catch {} + + res.json({ + status: response.ok ? 'connected' : 'disconnected', + channelCount, + pluginCount, + diskCount, + version, + hasUpdate, + latestVersion, + }); + } catch (err: any) { + res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message }); + } +}); + +/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */ +router.post('/admin/update-pansou', async (_req: Request, res: Response) => { + try { + execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 }); + execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 }); + try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {} + res.json({ success: true, message: 'PanSou 更新成功' }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' }); + } +}); + +export default router; diff --git a/packages/backend/src/cloud/cleanup.service.ts b/packages/backend/src/cloud/cleanup.service.ts new file mode 100755 index 0000000..1c836c0 --- /dev/null +++ b/packages/backend/src/cloud/cleanup.service.ts @@ -0,0 +1,254 @@ +import { getDb } from '../database/database'; +import { getSystemConfig, updateSystemConfig } from '../admin/system-config.service'; +import { formatLocalDate, formatLocalDateTime } from '../utils/time'; +import { QuarkDriver } from './drivers/quark.driver'; +import { BaiduDriver } from './drivers/baidu.driver'; + +// ═══════════════════════════════════════════════════════════════════════════ +// CloudCleanupDriver — contract that each cloud driver must fulfill +// to participate in the cleanup cycle. +// +// To add a new cloud type (e.g. Baidu, Aliyun), implement these three +// methods in the driver and register it in getDriverForCleanup() below. +// The controller (this file) handles WHEN and WITH WHAT parameters; +// the driver handles HOW. +// ═══════════════════════════════════════════════════════════════════════════ + +/** Each cleanup operation returns { trashed: number; errors: string[] } */ +interface CleanupOpResult { trashed: number; errors: string[] } + +interface CloudCleanupDriver { + /** Trash date folders (YYYY-MM-DD) older than `days`. */ + cleanupOldDateFolders(days: number): Promise; + /** + * If used space exceeds thresholdPercent% of TOTAL capacity, + * delete oldest date folders until totalBytes * deletePercent/100 + * of TOTAL capacity is freed. + * @param thresholdPercent — trigger when usage >= this % of total + * @param deletePercent — free this % of total capacity + */ + cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise; + /** Permanently empty the recycle bin. */ + emptyTrash(): Promise; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Driver factory — create the right driver for a given cloud config. +// When adding a new cloud type, add a case here. +// ═══════════════════════════════════════════════════════════════════════════ + +function getDriverForCleanup(config: { cloud_type: string; cookie: string }): CloudCleanupDriver | null { + switch (config.cloud_type) { + case 'quark': + return new QuarkDriver({ cookie: config.cookie }); + case 'baidu': + return new BaiduDriver({ cookie: config.cookie }); + // case 'aliyun': return new AliyunDriver({ cookie: config.cookie }); + default: + return null; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Cleanup controller — reads system configs and dispatches to each +// active cloud driver. Every driver receives the same parameters; +// the driver decides whether/how to act. +// ═══════════════════════════════════════════════════════════════════════════ + +interface CleanupStats { + filesTrashed: number; + logsDeleted: number; + trashEmptied: boolean; + errors: string[]; +} + +/** Get all active cloud configs (any type). Used by the orchestrator. */ +function getActiveCleanupConfigs(): Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }> { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, cookie, nickname FROM cloud_configs + WHERE is_active = 1 AND cookie IS NOT NULL AND cookie != ''` + ).all() as Array<{ id: number; cloud_type: string; cookie: string; nickname?: string }>; +} + +/** + * Dispatch cleanupOldDateFolders to every active driver. + * Each driver receives the same `days` parameter. + */ +async function cleanupCloudFiles(days: number): Promise { + const configs = getActiveCleanupConfigs(); + const errors: string[] = []; + let totalTrashed = 0; + + for (const cfg of configs) { + const driver = getDriverForCleanup(cfg); + if (!driver) { + console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`); + continue; + } + try { + const result = await driver.cleanupOldDateFolders(days); + totalTrashed += result.trashed; + errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); + } catch (err: any) { + errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupOldDateFolders: ${err.message}`); + } + await new Promise(r => setTimeout(r, 1000)); + } + + return { trashed: totalTrashed, errors }; +} + +/** + * Dispatch cleanupBySpaceThreshold to every active driver. + * Each driver receives the same threshold/delete percentages. + */ +async function cleanupAllBySpaceThreshold( + thresholdPercent: number, + deletePercent: number, +): Promise { + const configs = getActiveCleanupConfigs(); + const errors: string[] = []; + let totalTrashed = 0; + + for (const cfg of configs) { + const driver = getDriverForCleanup(cfg); + if (!driver) { + console.log(`[Cleanup] No driver for cloud_type="${cfg.cloud_type}", skipping config #${cfg.id}`); + continue; + } + try { + const result = await driver.cleanupBySpaceThreshold(thresholdPercent, deletePercent); + totalTrashed += result.trashed; + errors.push(...result.errors.map(e => `[${cfg.cloud_type}#${cfg.id}] ${e}`)); + } catch (err: any) { + errors.push(`[${cfg.cloud_type}#${cfg.id}] cleanupBySpaceThreshold: ${err.message}`); + } + await new Promise(r => setTimeout(r, 1000)); + } + + return { trashed: totalTrashed, errors }; +} + +/** + * Dispatch emptyTrash to every active driver. + */ +export async function emptyAllTrash(): Promise<{ emptied: boolean; errors: string[] }> { + const configs = getActiveCleanupConfigs(); + const errors: string[] = []; + let emptied = false; + + for (const cfg of configs) { + const driver = getDriverForCleanup(cfg); + if (!driver) continue; + try { + const ok = await driver.emptyTrash(); + if (ok) { + emptied = true; + console.log(`[Cleanup] ✅ Emptied trash for [${cfg.cloud_type}#${cfg.id}]`); + } else { + errors.push(`[${cfg.cloud_type}#${cfg.id}] empty trash failed`); + } + } catch (err: any) { + errors.push(`[${cfg.cloud_type}#${cfg.id}]: ${err.message}`); + } + } + + return { emptied, errors }; +} + +/** + * Delete save_records older than the specified number of days. + */ +function cleanupLogs(days: number): number { + const db = getDb(); + const cutoffStr = formatLocalDateTime(new Date(Date.now() - days * 24 * 60 * 60 * 1000)); + + const result = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoffStr); + console.log(`[Cleanup] Deleted ${result.changes} save records older than ${days} days (before ${cutoffStr})`); + return result.changes; +} + +/** + * Run full cleanup cycle: + * 0. Force-clean by space threshold (if enabled & exceeded) — priority highest + * 1. Delete old save_records + * 2. Trash old date folders by retention days + * 3. Empty recycle bin (permanently free space) + */ +export async function runFullCleanup(): Promise { + const fileDays = parseInt(getSystemConfig('cleanup_file_retention_days') || '7', 10); + const logDays = parseInt(getSystemConfig('cleanup_log_retention_days') || '30', 10); + const emptyTrashEnabled = getSystemConfig('cleanup_empty_trash') !== 'false'; + + const stats: CleanupStats = { filesTrashed: 0, logsDeleted: 0, trashEmptied: false, errors: [] }; + + // 0. Space threshold (highest priority) + const thresholdEnabled = getSystemConfig('cleanup_space_threshold_enabled'); + if (thresholdEnabled === 'true') { + const thresholdPercent = parseInt(getSystemConfig('cleanup_space_threshold_percent') || '90', 10); + const deletePercent = parseInt(getSystemConfig('cleanup_space_threshold_delete_percent') || '10', 10); + if (thresholdPercent > 0 && thresholdPercent < 100) { + try { + const result = await cleanupAllBySpaceThreshold(thresholdPercent, deletePercent); + stats.filesTrashed += result.trashed; + stats.errors.push(...result.errors); + } catch (err: any) { + stats.errors.push(`空间阈值清理失败: ${err.message}`); + } + } + } + + // 1. Delete old save_records + try { + stats.logsDeleted = cleanupLogs(logDays); + } catch (err: any) { + stats.errors.push(`日志清理失败: ${err.message}`); + } + + // 2. Trash old files from cloud drives + try { + const result = await cleanupCloudFiles(fileDays); + stats.filesTrashed += result.trashed; + stats.errors.push(...result.errors); + } catch (err: any) { + stats.errors.push(`文件清理失败: ${err.message}`); + } + + // 3. Empty recycle bin (only if enabled, and only if we trashed something) + if (emptyTrashEnabled && stats.filesTrashed > 0) { + try { + const result = await emptyAllTrash(); + stats.trashEmptied = result.emptied; + stats.errors.push(...result.errors); + } catch (err: any) { + stats.errors.push(`清空回收站失败: ${err.message}`); + } + } + + // Save last run timestamp and stats + updateSystemConfig('cleanup_last_run', formatLocalDateTime()); + updateSystemConfig('cleanup_last_stats', + JSON.stringify({ filesTrashed: stats.filesTrashed, logsDeleted: stats.logsDeleted, trashEmptied: stats.trashEmptied, errors: stats.errors.length }) + ); + + return stats; +} + +/** + * Check if a daily cleanup is due and run it. + * Called periodically by the scheduler (setInterval). + */ +export async function checkAndRunScheduledCleanup(): Promise { + const enabled = getSystemConfig('cleanup_enabled'); + if (enabled !== 'true') return; + + const lastRun = getSystemConfig('cleanup_last_run'); + const todayStr = formatLocalDate(); + + if (lastRun && lastRun.startsWith(todayStr)) return; + + console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`); + const stats = await runFullCleanup(); + console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`); +} diff --git a/packages/backend/src/cloud/cloud-types.service.ts b/packages/backend/src/cloud/cloud-types.service.ts new file mode 100755 index 0000000..3d20553 --- /dev/null +++ b/packages/backend/src/cloud/cloud-types.service.ts @@ -0,0 +1,69 @@ +import { getSystemConfig } from '../admin/system-config.service'; + +export interface CloudTypeInfo { + type: string; + label: string; + icon: string; + enabled: boolean; +} + +/** + * 网盘图标 — 使用打包进镜像的 PNG 图标文件 + * 图标存放在 /app/dist/frontend/icons/,通过 Express static 中间件对外提供 + */ +/** + * 网盘图标 — 内联 SVG data URI,无需外部文件 + */ +function makeSvgIcon(bg: string, letter: string): string { + const c = encodeURIComponent(bg); + const l = encodeURIComponent(letter); + return `data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22${c}%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2213%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E${l}%3C%2Ftext%3E%3C%2Fsvg%3E`; +} + +const ICONS: Record = { + baidu: makeSvgIcon('#4e6ef2', '百'), + aliyun: makeSvgIcon('#ff6a00', '阿'), + quark: makeSvgIcon('#07c160', '夸'), + '115': makeSvgIcon('#9b59b6', '1'), + tianyi: makeSvgIcon('#00a1d6', '天'), + '123pan': makeSvgIcon('#e74c3c', '1'), + uc: makeSvgIcon('#f39c12', 'U'), + xunlei: makeSvgIcon('#2ecc71', '迅'), + pikpak: makeSvgIcon('#8e44ad', 'P'), + magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E', + ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E', + others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E', +}; + +const ALL_CLOUD_TYPES: { type: string; label: string; icon: string }[] = [ + { type: 'quark', label: '夸克网盘', icon: ICONS.quark }, + { type: 'baidu', label: '百度网盘', icon: ICONS.baidu }, + { type: 'aliyun', label: '阿里云盘', icon: ICONS.aliyun }, + { type: '115', label: '115 网盘', icon: ICONS['115'] }, + { type: 'tianyi', label: '天翼云盘', icon: ICONS.tianyi }, + { type: '123pan', label: '123 云盘', icon: ICONS['123pan'] }, + { type: 'uc', label: 'UC 网盘', icon: ICONS.uc }, + { type: 'xunlei', label: '迅雷网盘', icon: ICONS.xunlei }, + { type: 'pikpak', label: 'PikPak', icon: ICONS.pikpak }, + { type: 'magnet', label: '磁力链接', icon: ICONS.magnet }, + { type: 'ed2k', label: '电驴链接', icon: ICONS.ed2k }, + { type: 'others', label: '其他', icon: ICONS.others }, +]; + +export function isCloudTypeEnabled(type: string): boolean { + const val = getSystemConfig(`cloud_type_${type}_enabled`); + if (val === null) return type !== 'others'; + return val === "true" || val === "1"; +} + +export function getAllCloudTypes(): CloudTypeInfo[] { + return ALL_CLOUD_TYPES.map(ct => ({ ...ct, enabled: isCloudTypeEnabled(ct.type) })); +} + +export function getEnabledCloudTypeSet(): Set { + const enabled = new Set(); + for (const ct of ALL_CLOUD_TYPES) { + if (isCloudTypeEnabled(ct.type)) enabled.add(ct.type); + } + return enabled; +} \ No newline at end of file diff --git a/packages/backend/src/cloud/cloud.service.ts b/packages/backend/src/cloud/cloud.service.ts new file mode 100644 index 0000000..a7b49ea --- /dev/null +++ b/packages/backend/src/cloud/cloud.service.ts @@ -0,0 +1,323 @@ +import { getDb } from '../database/database'; +import { localTimestamp, formatLocalDateTime } from '../utils/time'; +import { getSystemConfig } from '../admin/system-config.service'; +import { QuarkDriver } from './drivers/quark.driver'; +import { BaiduDriver } from './drivers/baidu.driver'; +import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service'; +import { lookupIpLocation } from './ip-lookup'; + +/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */ +const inFlightSaves = new Map>(); + +export interface SaveResult { + success: boolean; + shareUrl?: string; + share_url?: string; + sharePwd?: string; + folderName?: string; + message: string; + file_count?: number; + folder_count?: number; + duration_ms?: number; +} + +export interface SaveRecord { + id: number; + source_type: string; + source_title: string | null; + source_url: string; + target_cloud: string; + share_url: string | null; + share_pwd: string | null; + file_size: string | null; + file_count: number; + folder_count: number; + duration_ms: number; + status: string; + error_message: string | null; + folder_name: string | null; + original_folder_name: string | null; + ip_address: string | null; + ip_location: string | null; + created_at: string; +} + +/** Core save logic extracted so inFlight dedup can wrap it */ +async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise { + const db = getDb(); + const ipLocation = await lookupIpLocation(ipAddress || ''); + + // ── Short-term dedup: prevent duplicate saves of the same URL within 60 seconds ── + const DEDUP_WINDOW_SEC = 60; + let dedupCutoff = ''; + try { + const recentCutoff = db.prepare( + `SELECT datetime('now','localtime', '-${DEDUP_WINDOW_SEC} seconds') as cutoff` + ).get() as { cutoff: string }; + dedupCutoff = recentCutoff.cutoff; + + const recentRecord = db.prepare( + `SELECT share_url, share_pwd, status, error_message, folder_name, original_folder_name FROM save_records + WHERE source_url = ? AND created_at >= ? + ORDER BY created_at DESC LIMIT 1` + ).get(shareUrl, dedupCutoff) as { + share_url: string | null; share_pwd: string | null; status: string; + error_message: string | null; folder_name: string | null; original_folder_name: string | null; + } | undefined; + + if (recentRecord) { + const alreadySaved = recentRecord.status === 'success' || recentRecord.status === 'reused'; + if (alreadySaved && recentRecord.share_url) { + console.log(`[Share] 🛡️ Dedup: ${shareUrl} was saved ${DEDUP_WINDOW_SEC}s ago (status=${recentRecord.status}), returning existing share link`); + db.prepare( + `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + cloudType, sourceTitle || null, shareUrl, cloudType, + recentRecord.share_url, recentRecord.share_pwd || null, + null, 0, 0, 0, 'reused', null, + recentRecord.folder_name || null, recentRecord.original_folder_name || null, + ipAddress || null, ipLocation, localTimestamp(), + ); + return { + success: true, + message: `🛡️ 此资源刚在 ${DEDUP_WINDOW_SEC} 秒内转存过,直接返回已有分享链接`, + share_url: recentRecord.share_url, shareUrl: recentRecord.share_url, + sharePwd: recentRecord.share_pwd || '', folderName: '', + file_count: 0, folder_count: 0, duration_ms: 0, + }; + } + } + } catch (err: any) { + console.log(`[Share] Dedup check failed: ${err.message}, proceeding with normal save`); + } + + // ── Share link reuse: if same source URL was already saved successfully, validate and reuse ── + const reuseEnabled = getSystemConfig('save_reuse_enabled'); + if (reuseEnabled !== 'false') { + try { + const existing = db.prepare( + `SELECT share_url, share_pwd, folder_name, original_folder_name FROM save_records + WHERE source_url = ? AND status IN ('success', 'reused') AND share_url IS NOT NULL AND share_url != '' + ORDER BY created_at DESC LIMIT 1` + ).get(shareUrl) as { share_url: string; share_pwd: string; folder_name: string | null; original_folder_name: string | null } | undefined; + + if (existing?.share_url) { + const { LinkValidator } = await import('../validation/link-validator.service'); + const validator = new LinkValidator(); + const validation = await validator.validate(existing.share_url, 'quark'); + if (validation.status === 'valid') { + const isFirstReuse = dedupCutoff ? !db.prepare( + `SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1` + ).get(shareUrl, dedupCutoff) : true; + const reuseStatus = isFirstReuse ? 'success' : 'reused'; + const reuseMsg = isFirstReuse + ? `♻️ 检测到此资源之前已转存过,直接复用已存在的分享链接` + : `♻️ 短时间内重复请求,复用已有分享链接`; + + console.log(`[Share] ♻️ Reusing existing share link for ${shareUrl}: ${existing.share_url} (firstReuse=${isFirstReuse})`); + db.prepare( + `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + cloudType, sourceTitle || null, shareUrl, cloudType, + existing.share_url, existing.share_pwd || null, + null, 0, 0, 0, reuseStatus, null, + existing.folder_name || null, existing.original_folder_name || null, + ipAddress || null, ipLocation, localTimestamp(), + ); + return { + success: true, message: reuseMsg, + share_url: existing.share_url, shareUrl: existing.share_url, + sharePwd: existing.share_pwd || '', folderName: '', + file_count: 0, folder_count: 0, duration_ms: 0, + }; + } + console.log(`[Share] Existing share link for ${shareUrl} is invalid/expired, will re-save`); + } + } catch (err: any) { + console.log(`[Share] Link reuse check failed: ${err.message}, proceeding with normal save`); + } + } + + // ── Unified credential validation ── + const credential = await getAndValidateCredential(cloudType); + if (!credential.valid || !credential.config) { + return { success: false, message: credential.message }; + } + const config = credential.config; + + // ── Check transfer enabled ── + if (config.is_transfer_enabled === 0) { + return { success: false, message: `${config.nickname || cloudType} 的转存功能已关闭,请先在后台开启` }; + } + + const startTime = Date.now(); + + try { + let driverResult: { success: boolean; message: string; shareUrl?: string; sharePwd?: string; folderName?: string; fileCount?: number; folderCount?: number; originalFolderName?: string }; + + switch (cloudType) { + case 'quark': { + const driver = new QuarkDriver({ cookie: config.cookie!, nickname: config.nickname }); + driverResult = await driver.saveFromShare(shareUrl, sourceTitle); + break; + } + case 'baidu': { + const driver = new BaiduDriver({ cookie: config.cookie!, nickname: config.nickname }); + driverResult = await driver.saveFromShare(shareUrl, sourceTitle); + break; + } + case 'aliyun': + return { success: false, message: '阿里云盘保存功能暂未实现' }; + default: + return { success: false, message: `暂不支持 ${cloudType} 的保存功能` }; + } + + const durationMs = Date.now() - startTime; + + if (driverResult.success) { + db.prepare( + `UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?` + ).run(config.id); + } else if ((driverResult as any).cookieExpired) { + // Cookie expired — don't count as failure, user needs to re-login + } else { + db.prepare( + `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` + ).run(config.id); + } + + db.prepare( + `INSERT INTO save_records (source_type, source_title, source_url, target_cloud, share_url, share_pwd, file_size, file_count, folder_count, duration_ms, status, error_message, folder_name, original_folder_name, ip_address, ip_location, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + cloudType, sourceTitle || driverResult.folderName || null, shareUrl, cloudType, + driverResult.shareUrl || null, driverResult.sharePwd || null, + null, driverResult.fileCount || 0, driverResult.folderCount || 0, + durationMs, driverResult.success ? 'success' : 'failed', + driverResult.success ? null : driverResult.message, + driverResult.folderName || null, driverResult.originalFolderName || null, + ipAddress || null, ipLocation, localTimestamp(), + ); + + return { + success: driverResult.success, + message: driverResult.message, + share_url: driverResult.shareUrl || '', + shareUrl: driverResult.shareUrl, + sharePwd: (driverResult as any).sharePwd || '', + folderName: driverResult.folderName || '', + file_count: driverResult.fileCount || 0, + folder_count: driverResult.folderCount || 0, + duration_ms: durationMs, + }; + } catch (err: any) { + const durationMs = Date.now() - startTime; + const errorMessage = err.message || 'Failed to save to cloud'; + + db.prepare( + `UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?` + ).run(config.id); + + db.prepare( + `INSERT INTO save_records (source_type, source_url, target_cloud, duration_ms, status, error_message, ip_address, ip_location, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run(cloudType, shareUrl, cloudType, durationMs, 'failed', errorMessage, ipAddress || null, ipLocation, localTimestamp()); + + return { success: false, message: errorMessage }; + } +} + +export async function saveFromShare(shareUrl: string, cloudType: string, sourceTitle?: string, ipAddress?: string): Promise { + const key = `${cloudType}:${shareUrl}`; + + const inflight = inFlightSaves.get(key); + if (inflight) { + console.log(`[Share] ⏳ In-flight: ${shareUrl} — another save is already running, awaiting result`); + return inflight; + } + + const promise = doSaveFromShare(shareUrl, cloudType, sourceTitle, ipAddress); + inFlightSaves.set(key, promise); + try { + return await promise; + } finally { + inFlightSaves.delete(key); + } +} + +// ── Save Records ────────────────────────────────────────────────── + +export function getSaveRecords(page: number = 1, pageSize: number = 20, startDate?: string, endDate?: string, status?: string, sourceType?: string, keyword?: string): { total: number; records: SaveRecord[]; summary?: { total: number; success: number; failed: number; reused: number } } { + const db = getDb(); + const offset = (page - 1) * pageSize; + const conditions: string[] = []; + const params: any[] = []; + const summaryConditions: string[] = []; + const summaryParams: any[] = []; + if (startDate) { + conditions.push('created_at >= ?'); params.push(startDate); + summaryConditions.push('created_at >= ?'); summaryParams.push(startDate); + } + if (endDate) { + conditions.push('created_at < ?'); params.push(endDate); + summaryConditions.push('created_at < ?'); summaryParams.push(endDate); + } + if (status) { conditions.push('status = ?'); params.push(status); } + if (sourceType) { + conditions.push('source_type = ?'); params.push(sourceType); + summaryConditions.push('source_type = ?'); summaryParams.push(sourceType); + } + if (keyword) { conditions.push('source_title LIKE ?'); params.push(`%${keyword}%`); } + const where = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; + const total = (db.prepare(`SELECT COUNT(*) as count FROM save_records ${where}`).get(...params) as any).count; + const records = db.prepare( + `SELECT * FROM save_records ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?` + ).all(...params, pageSize, offset) as SaveRecord[]; + + const summaryWhere = summaryConditions.length > 0 ? 'WHERE ' + summaryConditions.join(' AND ') : ''; + const summaryRows = db.prepare( + `SELECT status, COUNT(*) as cnt FROM save_records ${summaryWhere} GROUP BY status` + ).all(...summaryParams) as { status: string; cnt: number }[]; + let sumTotal = 0, sumSuccess = 0, sumFailed = 0, sumReused = 0; + for (const r of summaryRows) { + sumTotal += r.cnt; + if (r.status === 'success') sumSuccess = r.cnt; + else if (r.status === 'failed') sumFailed = r.cnt; + else if (r.status === 'reused') sumReused = r.cnt; + } + const summary = { total: sumTotal, success: sumSuccess, failed: sumFailed, reused: sumReused }; + + return { total, records, summary }; +} + +export function cleanupOldSaveRecords(): void { + const db = getDb(); + const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)); + const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff); + console.log(`[Cleanup] Deleted ${deleted.changes} save records older than 60 days (before ${cutoff})`); +} + +// ── Storage Refresh ─────────────────────────────────────────────── + +export async function refreshAllStorageInfo(): Promise { + const configs = getActiveCloudConfigs().filter(c => c.cloud_type === 'quark' && c.cookie); + if (configs.length === 0) return; + + for (const cfg of configs) { + try { + const { QuarkDriver } = require('./drivers/quark.driver'); + const driver = new QuarkDriver({ cookie: cfg.cookie, nickname: cfg.nickname }); + const storage = await driver.getStorageInfo(); + if (storage.totalBytes > 0 || storage.usedBytes > 0) { + const db = getDb(); + db.prepare( + `UPDATE cloud_configs SET storage_used = ?, storage_total = ? WHERE id = ?` + ).run(storage.used, storage.total, cfg.id); + } + } catch (err: any) { + console.error(`[Storage] Failed to refresh quark#${cfg.id}:`, err.message); + } + } +} diff --git a/packages/backend/src/cloud/credential.service.ts b/packages/backend/src/cloud/credential.service.ts new file mode 100644 index 0000000..75dd6a0 --- /dev/null +++ b/packages/backend/src/cloud/credential.service.ts @@ -0,0 +1,472 @@ +import { getDb } from '../database/database'; +import { localTimestamp, formatLocalDate, formatLocalDateTime } from '../utils/time'; +import { encrypt, decrypt, isEncrypted } from '../utils/crypto'; + +// ── Background Used-Space Calculation ────────────────────────── + +/** + * Fire-and-forget: recursively calculate used space for a quark drive + * and update the database when done. + */ +async function calculateUsedSpaceAsync(cookie: string, configId: number): Promise { + const { calculateUsedSpace } = require('./drivers/quark-cleanup'); + const usedBytes = await calculateUsedSpace(cookie); + if (usedBytes > 0) { + const usedFormatted = usedBytes >= 1024 ** 4 + ? (usedBytes / 1024 ** 4).toFixed(1) + ' TB' + : usedBytes >= 1024 ** 3 + ? (usedBytes / 1024 ** 3).toFixed(1) + ' GB' + : (usedBytes / 1024 ** 2).toFixed(1) + ' MB'; + const db = getDb(); + db.prepare( + `UPDATE cloud_configs SET storage_used = ?, updated_at = ? WHERE id = ?` + ).run(usedFormatted, localTimestamp(), configId); + console.log(`[UsedSpace] Updated config #${configId}: used=${usedFormatted}`); + } +} + +export interface CloudConfig { + id: number; + cloud_type: string; + cookie?: string; + nickname?: string; + is_active: number; + promotion_account?: string; + is_transfer_enabled: number; + storage_used?: string; + storage_total?: string; + checkin_status: string; // 'none'|'success'|'failed'|'pending'|'skipped' + last_checkin_at?: string; + checkin_message?: string; + consecutive_failures: number; + last_used_at?: string; + total_saves: number; + created_at: string; + updated_at: string; + verification_status?: string; + cloud_type_uid?: string; +} + +// ── Cookie Encryption Helper ────────────────────────────────────── +/** Decrypt cookie. Handles legacy plaintext data transparently. */ +function decryptCookie(encrypted: string | null | undefined): string { + if (!encrypted) return ''; + // If already plaintext (legacy data), return as-is + if (!isEncrypted(encrypted)) return encrypted; + return decrypt(encrypted); +} + +/** + * Extract Quark __uid from cookie string. + * Used for dedup: same cloud_type + same __uid = same account. + */ +function extractQuarkUid(cookie: string): string | null { + const match = cookie.match(/(?:^|;\s*)__uid=([^;]+)/); + return match ? match[1] : null; +} + +// ── Config CRUD ────────────────────────────────────────────────── + +export function getCloudConfigs(): CloudConfig[] { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at, verification_status + FROM cloud_configs ORDER BY id ASC` + ).all() as CloudConfig[]; +} + +export function getAvailableClouds(): CloudConfig[] { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at + FROM cloud_configs WHERE is_active = 1 ORDER BY id ASC` + ).all() as CloudConfig[]; +} + +/** Returns the first active config matching the given cloud type. */ +export function getCloudConfigByType(cloudType: string): CloudConfig | undefined { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at, verification_status + FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 + ORDER BY id ASC LIMIT 1` + ).get(cloudType) as CloudConfig | undefined; +} + +export function getCloudConfigById(id: number): CloudConfig | undefined { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at, verification_status + FROM cloud_configs WHERE id = ?` + ).get(id) as CloudConfig | undefined; +} + +/** Returns all active cloud configs (used by save flow for cloud type switching). */ +export function getActiveCloudConfigs(): CloudConfig[] { + const db = getDb(); + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at + FROM cloud_configs WHERE is_active = 1 + ORDER BY cloud_type ASC, id ASC` + ).all() as CloudConfig[]; +} + +export function saveCloudConfig(data: { + id?: number; + cloud_type: string; + cookie?: string; + nickname?: string; + is_active?: number; + promotion_account?: string; + is_transfer_enabled?: number; + storage_used?: string; + storage_total?: string; +}): CloudConfig { + const db = getDb(); + // Encrypt cookie before storing + const encryptedCookie = data.cookie ? encrypt(data.cookie) : null; + + // Extract cloud_type_uid from cookie (Quark __uid) + let cloudTypeUid: string | null = null; + if (data.cookie) { + cloudTypeUid = extractQuarkUid(data.cookie); + } + + if (data.id) { + // Update by ID — always succeeds + db.prepare( + `UPDATE cloud_configs SET + cloud_type = COALESCE(?, cloud_type), + cookie = COALESCE(?, cookie), + nickname = COALESCE(?, nickname), + is_active = COALESCE(?, is_active), + promotion_account = COALESCE(?, promotion_account), + is_transfer_enabled = COALESCE(?, is_transfer_enabled), + storage_used = COALESCE(?, storage_used), + storage_total = COALESCE(?, storage_total), + cloud_type_uid = COALESCE(?, cloud_type_uid), + consecutive_failures = 0, + updated_at = ? + WHERE id = ?` + ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), data.id); + } else { + // Try to find existing config by cloud_type + cloud_type_uid + let existing: any = null; + if (cloudTypeUid) { + existing = db.prepare( + `SELECT id FROM cloud_configs WHERE cloud_type = ? AND cloud_type_uid = ? LIMIT 1` + ).get(data.cloud_type, cloudTypeUid); + } + + // Fallback: match by cloud_type alone (legacy records without cloud_type_uid) + if (!existing) { + existing = db.prepare( + 'SELECT id FROM cloud_configs WHERE cloud_type = ? AND is_active = 1 LIMIT 1' + ).get(data.cloud_type) as any; + } + + if (existing) { + db.prepare( + `UPDATE cloud_configs SET + cookie = COALESCE(?, cookie), + nickname = COALESCE(?, nickname), + is_active = COALESCE(?, is_active), + promotion_account = COALESCE(?, promotion_account), + is_transfer_enabled = COALESCE(?, is_transfer_enabled), + storage_used = COALESCE(?, storage_used), + storage_total = COALESCE(?, storage_total), + cloud_type_uid = COALESCE(?, cloud_type_uid), + consecutive_failures = 0, + updated_at = ? + WHERE id = ?` + ).run(encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null, localTimestamp(), existing.id); + + // Re-read savedId for return + const savedId = existing.id; + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at + FROM cloud_configs WHERE id = ?` + ).get(savedId) as CloudConfig; + } + + // No existing config found — insert new + db.prepare( + 'INSERT INTO cloud_configs (cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, cloud_type_uid, consecutive_failures) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)' + ).run(data.cloud_type, encryptedCookie || null, data.nickname || null, data.is_active ?? 1, data.promotion_account ?? '', data.is_transfer_enabled ?? 1, data.storage_used || null, data.storage_total || null, cloudTypeUid || null); + } + + const savedId = data.id || (db.prepare('SELECT last_insert_rowid() as id').get() as any).id; + return db.prepare( + `SELECT id, cloud_type, cookie, nickname, is_active, promotion_account, is_transfer_enabled, storage_used, storage_total, + cloud_type_uid, + checkin_status, last_checkin_at, checkin_message, consecutive_failures, + last_used_at, total_saves, created_at, updated_at + FROM cloud_configs WHERE id = ?` + ).get(savedId) as CloudConfig; +} + +export function deleteCloudConfig(id: number): boolean { + const db = getDb(); + const result = db.prepare('DELETE FROM cloud_configs WHERE id = ?').run(id); + return result.changes > 0; +} + +// ── Cookie Validation ──────────────────────────────────────────── + +async function fetchQuarkNickname(cookie: string): Promise { + const MAX_RETRIES = 2; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + const response = await fetch('https://pan.quark.cn/account/info?fr=pc&platform=pc', { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch', + 'Cookie': cookie, + 'Accept': 'application/json', + 'Referer': 'https://pan.quark.cn/', + }, + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) return null; + const data = await response.json() as any; + if (data?.data?.nickname) return data.data.nickname; + } catch { + if (attempt < MAX_RETRIES) { + await new Promise(r => setTimeout(r, 1500)); + continue; + } + } + } + return null; +} + +export async function testCloudConnection(id: number): Promise<{ + success: boolean; + message: string; + nickname?: string; + storage_used?: string; + storage_total?: string; +}> { + const config = getCloudConfigById(id); + if (!config) { + return { success: false, message: 'Cloud config not found' }; + } + + if (!config.cookie) { + return { success: false, message: 'Cookie not configured' }; + } + + try { + let valid = false; + let nickname = ''; + let storageUsed = config.storage_used || ''; + let storageTotal = config.storage_total || ''; + + if (config.cloud_type === 'baidu') { + const { BaiduDriver } = require('./drivers/baidu.driver'); + const driver = new BaiduDriver({ cookie: config.cookie, nickname: config.nickname }); + valid = await driver.validate(); + if (valid) { + const info = await driver.getUserInfo(); + if (info) { + nickname = config.nickname || info.nickname || '百度网盘'; + const fmt = (b: number) => b >= 1024**3 ? (b/1024**3).toFixed(2)+' GB' : (b/1024**2).toFixed(2)+' MB'; + storageUsed = fmt(info.usedBytes); + storageTotal = fmt(info.totalBytes); + } + } + } else { + const decodedCookie = decrypt(config.cookie); + const { QuarkDriver } = require('./drivers/quark.driver'); + const driver = new QuarkDriver({ cookie: decodedCookie, nickname: config.nickname }); + valid = await driver.validate(); + if (valid) { + nickname = config.nickname || (await fetchQuarkNickname(decodedCookie)) || '夸克网盘'; + const storage = await driver.getStorageInfoQuick(config.storage_total); + storageTotal = (storage.total !== '-' && storage.total !== '0 B') ? storage.total : (config.storage_total || ''); + storageUsed = (storage.used && storage.used !== '-' && storage.used !== '0 B') ? storage.used : (config.storage_used || ''); + } + } + + const db = getDb(); + if (!valid) { + db.prepare( + `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` + ).run(localTimestamp(), id); + return { success: false, message: '连接失败:Cookie 无效或已过期,或网络暂时异常' }; + } + + db.prepare( + `UPDATE cloud_configs SET nickname = ?, storage_total = ?, storage_used = ?, is_active = 1, verification_status = 'valid', updated_at = ? WHERE id = ?` + ).run(nickname, storageTotal, storageUsed, localTimestamp(), id); + + // Fire-and-forget: recalculate used space in background (slow for big drives) + if (config.cloud_type === 'quark') { + calculateUsedSpaceAsync(decrypt(config.cookie), id).catch(err => console.error(`[UsedSpace] Background calc failed for #${id}:`, err.message)); + } + + return { + success: true, + message: '连接成功', + nickname, + storage_used: storageUsed, + storage_total: storageTotal, + }; + } catch (err: any) { + try { + const db = getDb(); + db.prepare( + `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` + ).run(localTimestamp(), id); + } catch {} + return { success: false, message: `连接失败:${err.message || '未知错误'}` }; + } +} + +export async function testCloudConnectionWithCookie(cloudType: string, cookie: string): Promise<{ + success: boolean; + message: string; + nickname?: string; + storage_used?: string; + storage_total?: string; +}> { + try { + const { QuarkDriver } = require('./drivers/quark.driver'); + const driver = new QuarkDriver({ cookie, nickname: '' }); + const valid = await driver.validate(); + if (!valid) { + return { success: false, message: '连接失败:Cookie 无效或已过期' }; + } + const nickname = (await fetchQuarkNickname(cookie)) || cloudType; + // getStorageInfo may timeout from overseas servers, don't fail if it does + let storage: { used: string; total: string } = { used: '-', total: '-' }; + try { + const s = await driver.getStorageInfoQuick(); + if (s) { + storage = { used: s.used || '-', total: s.total || '-' }; + } + } catch { + // storage info is optional + } + return { + success: true, + message: '连接成功', + nickname, + storage_used: storage.used, + storage_total: storage.total, + }; + } catch (err: any) { + return { success: false, message: `连接失败:${err.message || '未知错误'}` }; + } +} + +// ── Unified Credential Validation ───────────────────────────────── + +export interface CredentialValidationResult { + valid: boolean; + config?: CloudConfig; + errorCode?: string; + message: string; +} + +/** + * Get and validate a credential for the given cloud type. + * + * This is the unified entry point for all save/transfer operations. + * It handles: + * 1. Finding an active config with < 5 consecutive failures (round-robin) + * 2. Validating cookie freshness via driver.validate() + * 3. Returning structured result with error codes + * + * Reference: search-ucmao get_and_validate_credential() pattern. + */ +export async function getAndValidateCredential(cloudType: string): Promise { + const db = getDb(); + + const config = db.prepare( + `SELECT * FROM cloud_configs + WHERE cloud_type = ? AND is_active = 1 + AND consecutive_failures < 5 + ORDER BY last_used_at ASC NULLS FIRST + LIMIT 1` + ).get(cloudType) as CloudConfig | undefined; + + if (!config) { + return { + valid: false, + errorCode: 'NO_AVAILABLE_DRIVE', + message: `Cloud type "${cloudType}" is not configured or no available drives`, + }; + } + + if (!config.cookie) { + return { + valid: false, + errorCode: 'COOKIE_MISSING', + message: `Cookie not configured for ${cloudType} drive #${config.id}`, + }; + } + + try { + // Decrypt cookie before validation + const decryptedCookie = decryptCookie(config.cookie); + if (!decryptedCookie) { + return { + valid: false, + errorCode: 'COOKIE_MISSING', + message: `Cookie not configured for ${cloudType} drive #${config.id}`, + }; + } + + let cookieValid = false; + if (cloudType === 'baidu') { + const { BaiduDriver } = require('./drivers/baidu.driver'); + const driver = new BaiduDriver({ cookie: decryptedCookie, nickname: config.nickname }); + cookieValid = await driver.validate(); + } else { + const { QuarkDriver } = require('./drivers/quark.driver'); + const driver = new QuarkDriver({ cookie: decryptedCookie, nickname: config.nickname }); + cookieValid = await driver.validate(); + } + + if (!cookieValid) { + db.prepare( + `UPDATE cloud_configs SET verification_status = 'invalid', updated_at = ? WHERE id = ?` + ).run(localTimestamp(), config.id); + return { + valid: false, + errorCode: 'COOKIE_EXPIRED', + message: `Cookie expired or invalid for ${cloudType} drive #${config.id}`, + }; + } + + return { + valid: true, + config: { ...config, cookie: decryptedCookie }, + message: 'ok', + }; + } catch (err: any) { + return { + valid: false, + errorCode: 'VALIDATION_ERROR', + message: `Credential validation failed: ${err.message}`, + }; + } +} diff --git a/packages/backend/src/cloud/database.ts b/packages/backend/src/cloud/database.ts new file mode 100755 index 0000000..829f073 --- /dev/null +++ b/packages/backend/src/cloud/database.ts @@ -0,0 +1,327 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import bcrypt from 'bcryptjs'; +import config from '../config'; +import { formatLocalDateTime } from '../utils/time'; + +let db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (db) return db; + + const dbDir = path.dirname(config.dbPath); + const fs = require('fs'); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + db = new Database(config.dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + runMigrations(db); + seedAdmin(db); + + return db; +} + +function runMigrations(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + last_login TEXT + ); + + CREATE TABLE IF NOT EXISTS cloud_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cloud_type TEXT NOT NULL, + cookie TEXT, + nickname TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + storage_used TEXT, + storage_total TEXT, + checkin_status TEXT NOT NULL DEFAULT 'none', + last_checkin_at TEXT, + checkin_message TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_used_at TEXT, + total_saves INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS promotions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + image_url TEXT, + link_url TEXT, + position TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + click_count INTEGER NOT NULL DEFAULT 0, + start_time TEXT, + end_time TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS save_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT, + source_title TEXT, + source_url TEXT, + target_cloud TEXT, + share_url TEXT, + share_pwd TEXT, + file_size TEXT, + file_count INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + error_message TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS search_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT, + intent TEXT, + result_count INTEGER DEFAULT 0, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS hot_keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT UNIQUE NOT NULL, + search_count INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS system_configs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + description TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS content_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT UNIQUE NOT NULL, + title TEXT, + description TEXT, + tags TEXT, + cover TEXT, + source TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + `); + seedSystemConfigs(db); + migrateSaveRecords(db); + migrateContentCache(db); + migrateCloudConfigs(db); + cleanupOldSaveRecords(db); +} + +/** 迁移: 给已有 save_records 表补充新列 */ +function migrateSaveRecords(db: Database.Database): void { + const newCols: { col: string; def: string }[] = [ + { col: 'share_pwd', def: 'TEXT' }, + { col: 'file_count', def: 'INTEGER DEFAULT 0' }, + { col: 'folder_count', def: 'INTEGER DEFAULT 0' }, + { col: 'duration_ms', def: 'INTEGER DEFAULT 0' }, + { col: 'status', def: "TEXT NOT NULL DEFAULT ''" }, + { col: 'error_message', def: 'TEXT' }, + { col: 'folder_name', def: 'TEXT' }, + { col: 'request_url', def: 'TEXT' }, + { col: 'ip_location', def: 'TEXT' }, + { col: 'original_folder_name', def: 'TEXT' }, + ]; + for (const { col, def } of newCols) { + try { + db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`); + } catch { + // Column already exists — ignore + } + } +} + +/** 迁移: 给 content_cache 表加 douban_url 列 */ +function migrateContentCache(db: Database.Database): void { + const columns: { col: string; def: string }[] = [ + { col: 'douban_url', def: 'TEXT' }, + { col: 'rating', def: 'TEXT' }, + { col: 'rating_count', def: 'TEXT' }, + { col: 'year', def: 'TEXT' }, + { col: 'genres', def: 'TEXT' }, + { col: 'directors', def: 'TEXT' }, + { col: 'actors', def: 'TEXT' }, + { col: 'region', def: 'TEXT' }, + { col: 'duration', def: 'TEXT' }, + ]; + for (const { col, def } of columns) { + try { + db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`); + } catch { + // Column already exists — ignore + } + } + // 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb + db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`); +} + +/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */ +function migrateCloudConfigs(db: Database.Database): void { + // 加新列 + const newCols: { col: string; def: string }[] = [ + { col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" }, + { col: 'last_checkin_at', def: 'TEXT' }, + { col: 'checkin_message', def: 'TEXT' }, + { col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' }, + { col: 'last_used_at', def: 'TEXT' }, + { col: 'total_saves', def: 'INTEGER DEFAULT 0' }, + ]; + for (const { col, def } of newCols) { + try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {} + } + // 检查旧表是否有 UNIQUE 约束,有则重建表 + const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any; + if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) { + db.exec(` + CREATE TABLE IF NOT EXISTS cloud_configs_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cloud_type TEXT NOT NULL, + cookie TEXT, + nickname TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + storage_used TEXT, + storage_total TEXT, + checkin_status TEXT NOT NULL DEFAULT 'none', + last_checkin_at TEXT, + checkin_message TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_used_at TEXT, + total_saves INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at) + SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs; + DROP TABLE cloud_configs; + ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs; + `); + console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added'); + } + + // Migration 2: Add verification_status column + const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get(); + if (!row2) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL"); + console.log('[DB] cloud_configs migration: verification_status column added'); + } + + // Migration 3: Add cloud_type_uid column (for Quark __uid dedup) + const row3 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get(); + if (!row3) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL"); + console.log('[DB] cloud_configs migration: cloud_type_uid column added'); + } + + // Migration 4: Add promotion_account column + const row4 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get(); + if (!row4) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT ''"); + console.log('[DB] cloud_configs migration: promotion_account column added'); + } + + // Migration 5: Add is_transfer_enabled column + const row5 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_transfer_enabled%'").get(); + if (!row5) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1"); + console.log('[DB] cloud_configs migration: is_transfer_enabled column added'); + } +} + +function seedAdmin(db: Database.Database): void { + const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); + if (existing) return; + + const salt = bcrypt.genSaltSync(10); + const hash = bcrypt.hashSync(config.adminPassword, salt); + + db.prepare( + 'INSERT INTO admins (username, password_hash) VALUES (?, ?)' + ).run(config.adminUsername, hash); + + console.log(`[DB] Admin user "${config.adminUsername}" created`); +} + +function seedSystemConfigs(db: Database.Database): void { + const defaults: { key: string; value: string; description: string }[] = [ + { key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' }, + { key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' }, + { key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' }, + { key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' }, + { key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' }, + { key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' }, + { key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' }, + { key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' }, + { key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' }, + { key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' }, + { key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' }, + { key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' }, + { key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' }, + { key: 'cloud_enabled_115', value: 'true', description: '115 网盘' }, + { key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' }, + { key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' }, + { key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' }, + { key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' }, + { key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' }, + { key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' }, + { key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' }, + { key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' }, + { key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' }, + { key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' }, + { key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' }, + { key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' }, + { key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' }, + { key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' }, + { key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' }, + { key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=ca7ccb3b9ca044dd993c8604bc9afd93&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' }, + { key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' }, + { key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' }, + { key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' }, + { key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' }, + { key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' }, + { key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' }, + { key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' }, + { key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' }, + { key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' }, + { key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' }, + { key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' }, + { key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' }, + { key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' }, + { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, + { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, + { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, + ]; + const insert = db.prepare( + 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' + ); + for (const entry of defaults) { + insert.run(entry.key, entry.value, entry.description); + } +} + +/** 清理 60 天前的转存记录 */ +function cleanupOldSaveRecords(db: Database.Database): void { + const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)); + const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff); + console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`); +} + +export default getDb; diff --git a/packages/backend/src/cloud/drivers/CloudConfig.vue b/packages/backend/src/cloud/drivers/CloudConfig.vue new file mode 100755 index 0000000..733528f --- /dev/null +++ b/packages/backend/src/cloud/drivers/CloudConfig.vue @@ -0,0 +1,623 @@ + + + + + diff --git a/packages/backend/src/cloud/drivers/aliyun.driver.ts b/packages/backend/src/cloud/drivers/aliyun.driver.ts new file mode 100755 index 0000000..f5ab72c --- /dev/null +++ b/packages/backend/src/cloud/drivers/aliyun.driver.ts @@ -0,0 +1,113 @@ +// Native fetch available in Node 20+ + +export interface AliyunConfig { + cookie?: string; + nickname?: string; +} + +export class AliyunDriver { + private config: AliyunConfig; + private baseUrl = 'https://api.aliyundrive.com'; + + constructor(config: AliyunConfig = {}) { + this.config = config; + } + + /** + * Extract share_id from an Aliyun share URL. + * Supports: + * https://www.aliyundrive.com/s/XXXYYY + * https://www.alipan.com/s/XXXYYY + * https://api.aliyundrive.com/v2/share_link/XXXYYY + */ + private extractShareId(shareUrl: string): string | null { + try { + const url = new URL(shareUrl); + const pathMatch = url.pathname.match(/\/s\/([a-zA-Z0-9]+)/); + if (pathMatch) return pathMatch[1]; + + const shareMatch = url.pathname.match(/\/share_link\/([a-zA-Z0-9]+)/); + if (shareMatch) return shareMatch[1]; + + return null; + } catch { + return null; + } + } + + /** + * Validate a share link using Aliyun's public anonymous API. + * No cookie or token required — this endpoint is open. + * + * API: + * POST https://api.aliyundrive.com/v2/share_link/get_share_by_anonymous + * Body: { "share_id": "XXXYYY", "share_pwd": "" } + * + * Success: returns share_name, file_infos, creator info + * Failure: returns error code (ShareLinkExpired, ShareLinkCancelled, etc.) + */ + async validateShareLink(shareUrl: string): Promise<{ + valid: boolean; + message: string; + fileCount?: number; + shareName?: string; + }> { + const shareId = this.extractShareId(shareUrl); + if (!shareId) { + return { valid: false, message: '无法解析阿里云盘链接格式' }; + } + + try { + const response = await fetch( + `${this.baseUrl}/v2/share_link/get_share_by_anonymous`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://www.aliyundrive.com/', + 'Accept-Language': 'zh-CN,zh;q=0.9', + }, + body: JSON.stringify({ + share_id: shareId, + share_pwd: '', + }), + signal: AbortSignal.timeout(10000), + } + ); + + if (!response.ok) { + return { valid: false, message: `HTTP ${response.status}: API 请求失败` }; + } + + const data = await response.json() as any; + + // Check for error codes + if (data.code) { + switch (data.code) { + case 'ShareLinkExpired': + return { valid: false, message: '分享已失效(已过期)' }; + case 'ShareLinkCancelled': + return { valid: false, message: '分享已被取消' }; + case 'NotFound.ShareLink': + return { valid: false, message: '分享链接不存在' }; + case 'ShareLinkPasswordIncorrect': + return { valid: true, message: '需要提取码(链接有效)' }; + default: + return { valid: false, message: data.message || `未知错误 (${data.code})` }; + } + } + + // Success — valid share link + const fileInfos = data.file_infos || []; + return { + valid: true, + message: `有效链接(${fileInfos.length} 个文件)`, + fileCount: fileInfos.length, + shareName: data.share_name || '', + }; + } catch (err: any) { + return { valid: false, message: `网络错误: ${err.message || err}` }; + } + } +} diff --git a/packages/backend/src/cloud/drivers/baidu.driver.ts b/packages/backend/src/cloud/drivers/baidu.driver.ts new file mode 100644 index 0000000..bba0333 --- /dev/null +++ b/packages/backend/src/cloud/drivers/baidu.driver.ts @@ -0,0 +1,1189 @@ +// Baidu Netdisk Driver v4 — Cookie-based (Playwright QR login + HTTP API) +// Uses full browser Cookie string for all operations (no OAuth access_token needed). +// Share operations use internal web API (/share/verify, /share/transfer, parse HTML). +// Reference: https://github.com/hxz393/BaiduPanFilesTransfers +// +// v4 changes from v3: +// - QR login via Playwright browser → captures full Cookie string (BDUSS + BAIDUID + STOKEN + ...) +// - getShareFiles uses Cookie HTTP: getbdstoken → verify password → GET share page → regex parse +// - transferFiles uses Cookie HTTP: POST /share/transfer +// - File list/create/delete use Cookie-based /api/* endpoints with bdstoken +// - Removed OAuth Device Code flow (no more access_token) + +import type { Browser, BrowserContext, Page } from 'playwright'; + +export interface BaiduConfig { + cookie?: string; // Full Cookie string: "BDUSS=xxx; BAIDUID=yyy; STOKEN=zzz; ..." + bdstoken?: string; // Cached bdstoken from /api/gettemplatevariable + cookieExpired?: boolean; // Flag set when cookie validation fails + nickname?: string; +} + +interface ShareFileInfo { + server_filename: string; + fs_id: string; + isdir: number; + size: number; + path: string; + category: number; +} + +interface ShareDetail { + files: ShareFileInfo[]; + childFiles: ShareFileInfo[] | null; +} + +// ═══════════════════════════════════ +// Constants +// ═══════════════════════════════════ +const API_HOST = "https://pan.baidu.com"; +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || "/usr/bin/chromium-browser"; +const APP_ID_WEB = "38824127"; // Web app ID from BaiduPanFilesTransfers + +// HTTP headers matching BaiduPanFilesTransfers +const WEB_HEADERS: Record = { + 'Host': 'pan.baidu.com', + 'Connection': 'keep-alive', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36', + 'Referer': 'https://pan.baidu.com', +}; + +function buildHeaders(cookie: string): Record { + if (cookie) { + return { ...WEB_HEADERS, 'Cookie': cookie }; + } + return { ...WEB_HEADERS }; +} + +// ═══════════════════════════════════ +// Playwright singleton for QR login +// ═══════════════════════════════════ +let _browser: Browser | null = null; + +async function getBrowserSingleton(): Promise { + const { chromium } = await import("playwright"); + if (!_browser || !_browser.isConnected()) { + _browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: [ + "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", + "--disable-gpu", "--disable-software-rasterizer", + "--disable-features=Vulkan", "--use-gl=swiftshader", + ], + timeout: 30000, + }); + } + return _browser; +} + +// ═══════════════════════════════════ +// QR login session store +// ═══════════════════════════════════ +const qrSessions = new Map(); + +// ═══════════════════════════════════ +// Helpers +// ═══════════════════════════════════ + +function dailyFolderName(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +async function humanDelay(): Promise { + await new Promise(r => setTimeout(r, 500 + Math.random() * 1000)); +} + +// Extract short_url from share link: /s/1XXXXX... → strip leading '1' +function extractShortUrl(shareUrl: string): { surl: string; pwd: string } | null { + try { + const url = new URL(shareUrl); + if (!url.hostname.includes('pan.baidu.com')) return null; + const pathMatch = url.pathname.match(/^\/s\/(1[a-zA-Z0-9_-]+)/); + if (!pathMatch) return null; + const surl = pathMatch[1].slice(1); + if (surl.length < 20) return null; + const pwd = url.searchParams.get('pwd') || ''; + return { surl, pwd }; + } catch { + return null; + } +} + +// Regex patterns for parsing share page HTML +const RE_SHAREID = /"shareid":(\d+?),"/; +const RE_SHARE_UK = /"share_uk":"(\d+?)","/; +const RE_FSID = /"fs_id":(\d+?),"/; +const RE_FILENAME = /"server_filename":"(.+?)","/; +const RE_ISDIR = /"isdir":(\d+?),"/; +const RE_SIZE = /"size":(\d+?),"/; +const RE_CATEGORY = /"category":(\d+?),"/; + +// ═══════════════════════════════════ +// BaiduDriver +// ═══════════════════════════════════ + +export class BaiduDriver { + private config: BaiduConfig; + + constructor(config: BaiduConfig = {}) { + this.config = { ...config }; + } + + private getCookie(): string { + return this.config.cookie || ''; + } + + private async getBdstoken(): Promise { + // Use cached if available + if (this.config.bdstoken) { + // Validate: bdstoken is typically ~32 chars hex + if (this.config.bdstoken.length > 10) return this.config.bdstoken; + } + + const cookie = this.getCookie(); + if (!cookie) return null; + + try { + const url = `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","token","uk","isdocuser","servertime"]`; + const res = await fetch(url, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) { + console.error('[Baidu] getBdstoken HTTP', res.status); + return null; + } + const data = await res.json() as any; + if (data.errno !== 0) { + console.error('[Baidu] getBdstoken errno:', data.errno); + // errno -6 = cookie expired / invalid + if (data.errno === -6) { + this.config.cookieExpired = true; + console.error('[Baidu] Cookie expired — user needs to re-scan QR code'); + } + return null; + } + const bdstoken = data.result?.bdstoken || ''; + if (bdstoken) { + this.config.bdstoken = bdstoken; + console.log('[Baidu] bdstoken obtained'); + return bdstoken; + } + return null; + } catch (err: any) { + console.error('[Baidu] getBdstoken error:', err.message); + return null; + } + } + + // ═══════════════════════════════════ + // QR Login — Playwright browser + // ═══════════════════════════════════ + + static async startQrLogin(): Promise<{ qrUrl?: string; sessionId: string }> { + const sessionId = "baidu_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8); + let browser: Browser | null = null; + let context: BrowserContext | null = null; + let page: Page | null = null; + + try { + browser = await getBrowserSingleton(); + context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + locale: "zh-CN", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ignoreHTTPSErrors: true, + }); + // Anti-detection + await context.addInitScript(`(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); })()`); + + page = await context.newPage(); + + // Navigate directly to passport QR login page (the actual login page with QR code) + console.log('[BaiduQR] Navigating to passport QR login...'); + await page.goto('https://passport.baidu.com/v2/?login&qrlogin&tpl=netdisk', { waitUntil: 'commit', timeout: 30000 }); + await page.waitForTimeout(4000); + + // Check if we landed on the right page + const currentUrl = page.url(); + console.log('[BaiduQR] Current URL:', currentUrl); + + // Wait for QR code image on passport page + console.log('[BaiduQR] Waiting for QR code...'); + await page.waitForSelector('img[src*="passport.baidu.com/v2/api/qrcode"], img[src*="qrcode"]', { timeout: 20000 }); + await page.waitForTimeout(1500); + + // Extract QR code image URL + const qrImgSrc = await page.evaluate(`(() => { + const imgs = document.querySelectorAll('img'); + for (const img of imgs) { + const src = img.src || ''; + if (src.includes('passport.baidu.com/v2/api/qrcode') || (src.includes('qrcode') && img.width > 100)) { + return src; + } + } + return ''; + })()`) as string; + + if (!qrImgSrc) { + throw new Error('Could not find QR code image on page'); + } + + // Parse sign + logPage from QR image URL, construct wappass login URL + let qrContent = ''; + try { + const imgUrlObj = new URL(qrImgSrc); + const sign = imgUrlObj.searchParams.get('sign') || ''; + const logPage = imgUrlObj.searchParams.get('logPage') || ''; + const t = Math.floor(Date.now() / 1000); + qrContent = `https://wappass.baidu.com/wp/?qrlogin&t=${t}&error=0&sign=${sign}&cmd=login&lp=pc&tpl=netdisk&adapter=3&logPage=${encodeURIComponent(logPage)}&qrloginfrom=pc`; + } catch { + qrContent = qrImgSrc; // fallback: raw image URL + } + + // Store session + qrSessions.set(sessionId, { + browser: browser, + context, + page, + startTime: Date.now(), + verifying: false, + }); + + console.log('[BaiduQR] Session stored:', sessionId); + return { sessionId, qrUrl: qrContent }; + + } catch (err: any) { + console.error('[BaiduQR] startQrLogin error:', err.message); + // Cleanup on error + if (page) try { await page.close(); } catch {} + if (context) try { await context.close(); } catch {} + throw new Error('启动百度扫码失败: ' + err.message); + } + // Note: browser/context/page NOT closed on success — need them for status polling + } + + static async getQrLoginStatus(sessionId: string): Promise<{ + status: string; + cookie?: string; + nickname?: string; + bdstoken?: string; + storage_used?: string; + storage_total?: string; + }> { + const session = qrSessions.get(sessionId); + if (!session) return { status: 'expired' }; + + const { page, context, browser } = session; + + // Prevent concurrent status checks (lock) + if (session.verifying) { + console.log('[BaiduQR] Status check already in progress, returning pending'); + return { status: 'pending' }; + } + session.verifying = true; + + // 300s expiry + if (Date.now() - session.startTime > 300000) { + try { await context.close(); } catch {} + session.verifying = false; + qrSessions.delete(sessionId); + return { status: 'expired' }; + } + + try { + // Check cookies for BDUSS (login indicator) + const cookies = await context.cookies(); + const hasBDUSS = cookies.some((c: any) => { + if (c.name === 'BDUSS' && c.value && c.value.length > 50) return true; + return false; + }); + + // Check page for login completion + const currentUrl = page.url(); + const bodyText = await page.evaluate(`(() => (document.body?.innerText || ''))()`) as string; + + // Login detection signals + const qrGone = !(await page.$('img[src*="qrcode"]')); + const loginSuccess = bodyText.includes('登录成功') || bodyText.includes('确认登录'); + const onPanPage = currentUrl.includes('pan.baidu.com/disk'); + + if (hasBDUSS && (qrGone || loginSuccess || onPanPage)) { + // Login detected! Wait for redirect to pan.baidu.com + console.log('[BaiduQR] Login detected, waiting for redirect...'); + + // Wait up to 15s for page to settle on pan.baidu.com + for (let i = 0; i < 15; i++) { + await page.waitForTimeout(1000); + const newUrl = page.url(); + if (newUrl.includes('pan.baidu.com') && !newUrl.includes('passport')) break; + } + + // Navigate to disk home to ensure cookies are fully set + try { + await page.goto('https://pan.baidu.com/disk/home', { waitUntil: 'commit', timeout: 15000 }); + await page.waitForTimeout(2000); + } catch {} + + // Capture ALL cookies + const allCookies = await context.cookies(); + const cookieStr = allCookies + .map((c: any) => `${c.name}=${c.value}`) + .join('; '); + + console.log(`[BaiduQR] Login success! Got ${allCookies.length} cookies, BDUSS=${hasBDUSS}`); + + // Get nickname and bdstoken + let nickname = ''; + let bdstoken = ''; + try { + const bdres = await fetch( + `${API_HOST}/api/gettemplatevariable?clienttype=0&app_id=${APP_ID_WEB}&web=1&fields=["bdstoken","uk"]`, + { headers: buildHeaders(cookieStr), signal: AbortSignal.timeout(10000) } + ); + if (bdres.ok) { + const bddata = await bdres.json() as any; + if (bddata.errno === 0) { + bdstoken = bddata.result?.bdstoken || ''; + } + } + } catch {} + + // Get storage info + let storage_used = ''; + let storage_total = ''; + if (bdstoken) { + try { + const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${bdstoken}`, { + headers: buildHeaders(cookieStr), + signal: AbortSignal.timeout(10000), + }); + if (qRes.ok) { + const qData = await qRes.json() as any; + if (qData.errno === 0) { + const fmt = (bytes: number) => { + if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB'; + if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB'; + if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB'; + return (bytes / 1024).toFixed(2) + ' KB'; + }; + storage_used = qData.used ? fmt(qData.used) : ''; + storage_total = qData.total ? fmt(qData.total) : ''; + } + } + } catch {} + } + + // Get nickname from Baidu REST API (baidu_name field) + if (bdstoken) { + try { + const uRes = await fetch(`${API_HOST}/rest/2.0/xpan/nas?method=uinfo`, { + headers: buildHeaders(cookieStr), + signal: AbortSignal.timeout(10000), + }); + if (uRes.ok) { + const uData = await uRes.json() as any; + if (uData.errno === 0 && uData.baidu_name) { + nickname = uData.baidu_name; + } + } + } catch {} + } + + // Cleanup + session.verifying = false; + try { await context.close(); } catch {} + qrSessions.delete(sessionId); + + return { + status: 'logged_in', + cookie: cookieStr, + nickname: nickname || '百度用户', + bdstoken, + storage_used, + storage_total, + }; + } + + // Still pending + session.verifying = false; + return { status: 'pending' }; + + } catch (err: any) { + console.error('[BaiduQR] Status check error:', err.message); + session.verifying = false; + return { status: 'pending' }; + } + } + + static cancelQrLogin(sessionId: string) { + const session = qrSessions.get(sessionId); + if (session) { + const { context } = session; + try { context.close(); } catch {} + qrSessions.delete(sessionId); + console.log('[BaiduQR] Cancelled:', sessionId); + } + } + + // ═══════════════════════════════════ + // Validate — check cookie validity + // ═══════════════════════════════════ + + async validate(): Promise { + const cookie = this.getCookie(); + if (!cookie || !cookie.includes('BDUSS')) return false; + const bdstoken = await this.getBdstoken(); + return bdstoken !== null; + } + + async getUserInfo(): Promise<{ nickname: string; usedBytes: number; totalBytes: number } | null> { + const cookie = this.getCookie(); + if (!cookie) return null; + + try { + let nickname = this.config.nickname || ''; + let usedBytes = 0; + let totalBytes = 0; + + // Try to get user info from /api/userinfo + const uRes = await fetch(`${API_HOST}/api/userinfo?act=getuserinfo&bdstoken=${await this.getBdstoken()}`, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (uRes.ok) { + const uData = await uRes.json() as any; + if (uData.errno === 0 && uData.records) { + nickname = uData.records[0]?.username || nickname; + } + } + + // Get quota + try { + const qRes = await fetch(`${API_HOST}/api/quota?checkfree=1&checkexpire=1&bdstoken=${await this.getBdstoken()}`, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (qRes.ok) { + const qData = await qRes.json() as any; + if (qData.errno === 0) { + usedBytes = qData.used || 0; + totalBytes = qData.total || 0; + } + } + } catch {} + + return { nickname, usedBytes, totalBytes }; + } catch { + return null; + } + } + + async getStorageInfo(): Promise<{ used: string; total: string }> { + const info = await this.getUserInfo(); + if (!info) return { used: '0 B', total: '0 B' }; + const fmt = (bytes: number) => { + if (bytes >= 1024**4) return (bytes / 1024**4).toFixed(2) + ' TB'; + if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(2) + ' GB'; + if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(2) + ' MB'; + return (bytes / 1024).toFixed(2) + ' KB'; + }; + return { used: fmt(info.usedBytes), total: fmt(info.totalBytes) }; + } + + // ═══════════════════════════════════ + // File list (Cookie-based) + // ═══════════════════════════════════ + + private async listRootDir(): Promise> { + const cookie = this.getCookie(); + if (!cookie) return []; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return []; + + try { + const url = `${API_HOST}/api/list?order=time&desc=1&showempty=0&web=1&page=1&num=1000&dir=/&bdstoken=${bdstoken}`; + const res = await fetch(url, { + headers: buildHeaders(cookie), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) return []; + const data = await res.json() as any; + if (data.errno === 0 && data.list) { + return data.list.map((f: any) => ({ + fid: String(f.fs_id), + file_name: f.server_filename, + dir: f.isdir === 1 || f.isdir === '1', + size: f.size || 0, + })); + } + console.error('[Baidu] listRootDir errno:', data.errno); + return []; + } catch (err: any) { + console.error('[Baidu] listRootDir error:', err.message); + return []; + } + } + + private async createDir(path: string): Promise { + const cookie = this.getCookie(); + if (!cookie) return false; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + try { + const url = `${API_HOST}/api/create?a=commit&bdstoken=${bdstoken}`; + const body = new URLSearchParams({ path, isdir: '1', block_list: '[]' }); + const res = await fetch(url, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) return false; + const data = await res.json() as any; + if (data.errno === 0) return true; + if (data.errno === -8) return true; // already exists + console.error('[Baidu] createDir errno:', data.errno); + return false; + } catch (err: any) { + console.error('[Baidu] createDir error:', err.message); + return false; + } + } + + private async findOrCreateDir(dirName: string): Promise { + const rootItems = await this.listRootDir(); + const existing = rootItems.find(f => f.file_name === dirName && f.dir); + if (existing) return `/${dirName}`; + + const ok = await this.createDir(`/${dirName}`); + if (ok) { + console.log(`[Baidu] Created dir: ${dirName}`); + return `/${dirName}`; + } + return null; + } + + // ═══════════════════════════════════ + // Delete files + // ═══════════════════════════════════ + + private async deleteFiles(fsIds: string[]): Promise { + const cookie = this.getCookie(); + if (!cookie) return false; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + try { + const filelist = JSON.stringify(fsIds); + const body = new URLSearchParams({ async: '2', filelist }); + const res = await fetch(`${API_HOST}/api/filemanager?opera=delete&bdstoken=${bdstoken}`, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(30000), + }); + if (!res.ok) return false; + const data = await res.json() as any; + return data.errno === 0; + } catch (err: any) { + console.error('[Baidu] deleteFiles error:', err.message); + return false; + } + } + + // ═══════════════════════════════════ + // Validate share link + // ═══════════════════════════════════ + + async validateShareLink(shareUrl: string): Promise<{ valid: boolean; message: string; fileCount?: number }> { + const parsed = extractShortUrl(shareUrl); + if (!parsed) return { valid: false, message: '无法解析百度网盘链接格式' }; + const { surl, pwd } = parsed; + + try { + // Try to get share file list + const shareInfo = await this.getShareFiles(surl, pwd); + if (shareInfo && shareInfo.files.length > 0) { + return { valid: true, message: `有效(${shareInfo.files.length} 个文件)`, fileCount: shareInfo.files.length }; + } + return { valid: false, message: '链接已过期或需要提取码' }; + } catch (err: any) { + return { valid: false, message: `验证失败: ${err.message}` }; + } + } + + // ═══════════════════════════════════ + // Get share file list — Cookie HTTP + // ═══════════════════════════════════ + // Flow: getbdstoken → verify password (get randsk) → update cookie with BDCLND → GET share page → regex parse + + private async getShareFiles(surl: string, pwd: string): Promise { + const cookie = this.getCookie(); + if (!cookie) { + console.log('[Baidu] No cookie available for share file listing'); + return null; + } + + const bdstoken = await this.getBdstoken(); + if (!bdstoken) { + console.log('[Baidu] No bdstoken available'); + return null; + } + + let workingCookie = cookie; + + try { + // Step 1: Verify password and get randsk (BDCLND) + if (pwd) { + console.log(`[Baidu:Share] Verifying password for surl=${surl}...`); + const t = String(Date.now()); + const verifyUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`; + const verifyBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' }); + + const vRes = await fetch(verifyUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: verifyBody.toString(), + signal: AbortSignal.timeout(10000), + }); + + if (!vRes.ok) { + console.log(`[Baidu:Share] Password verify HTTP ${vRes.status}`); + return null; + } + const vData = await vRes.json() as any; + + if (vData.errno !== 0) { + // Error codes: -9/-12 = wrong password, 105 = not found, -62 = blocked + const errMap: Record = { '-9': '提取码错误', '-12': '提取码错误', '105': '链接不存在', '-62': '访问次数过多', '2': '链接已过期', '-33': '转存失败' }; + console.log(`[Baidu:Share] Password verify failed: errno=${vData.errno} — ${errMap[vData.errno] || '未知错误'}`); + return null; + } + + const randsk = vData.randsk || ''; + if (randsk) { + // Update cookie with BDCLND=randsk (per BaiduPanFilesTransfers) + workingCookie = updateCookie(workingCookie, 'BDCLND', randsk); + console.log('[Baidu:Share] Password verified, BDCLND updated'); + } + } + + // Step 2: GET share page with updated cookie + const shareUrl = `https://pan.baidu.com/s/1${surl}`; + console.log(`[Baidu:Share] Fetching share page: ${shareUrl}`); + const sRes = await fetch(shareUrl, { + headers: buildHeaders(workingCookie), + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + + if (!sRes.ok) { + console.log(`[Baidu:Share] Share page HTTP ${sRes.status}`); + return null; + } + + const html = await sRes.text(); + + // Check for error states + if (html.includes('页面不存在') || html.includes('你来晚了') || html.includes('链接已失效') || html.includes('分享已过期')) { + console.log('[Baidu:Share] Share link is dead/expired'); + return null; + } + + // Step 3: Parse HTML for file info using regex + const shareidMatch = html.match(RE_SHAREID); + const ukMatch = html.match(RE_SHARE_UK); + const fsIdMatches = [...html.matchAll(new RegExp(RE_FSID.source, 'g'))]; + const filenameMatches = [...html.matchAll(new RegExp(RE_FILENAME.source, 'g'))]; + const isdirMatches = [...html.matchAll(new RegExp(RE_ISDIR.source, 'g'))]; + const sizeMatches = [...html.matchAll(new RegExp(RE_SIZE.source, 'g'))]; + const categoryMatches = [...html.matchAll(new RegExp(RE_CATEGORY.source, 'g'))]; + + if (!shareidMatch || !ukMatch || fsIdMatches.length === 0) { + // Try alternative extraction from yunData in script tags + const yunMatch = html.match(/yunData\.setData\((\{[^]*?\})\);?/); + if (yunMatch) { + try { + const yunData = JSON.parse(yunMatch[1]); + if (yunData.filelist && yunData.filelist.length > 0) { + const files: ShareFileInfo[] = yunData.filelist.map((f: any) => ({ + server_filename: f.server_filename || '', + fs_id: String(f.fs_id), + isdir: f.isdir || 0, + size: f.size || 0, + path: f.path || '', + category: f.category || 0, + })); + console.log(`[Baidu:Share] Found ${files.length} file(s) via yunData`); + return { files, childFiles: null }; + } + } catch {} + } + + console.log('[Baidu:Share] Could not parse file list from page'); + return null; + } + + const count = fsIdMatches.length; + const files: ShareFileInfo[] = []; + for (let i = 0; i < count; i++) { + files.push({ + server_filename: filenameMatches[i] ? filenameMatches[i][1] : '', + fs_id: fsIdMatches[i][1], + isdir: isdirMatches[i] ? parseInt(isdirMatches[i][1]) : 0, + size: sizeMatches[i] ? parseInt(sizeMatches[i][1]) : 0, + path: '', + category: categoryMatches[i] ? parseInt(categoryMatches[i][1]) : 0, + }); + } + + console.log(`[Baidu:Share] Found ${files.length} file(s) via regex parse`); + return { files, childFiles: null }; + + } catch (err: any) { + console.error('[Baidu:Share] getShareFiles error:', err.message); + return null; + } + } + + // ═══════════════════════════════════ + // Transfer files — Cookie HTTP + // ═══════════════════════════════════ + + private async transferFiles( + surl: string, pwd: string, + fsIds: string[], destPath: string + ): Promise<{ success: boolean; taskId?: string; message: string }> { + const cookie = this.getCookie(); + if (!cookie) return { success: false, message: '未登录百度网盘' }; + + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return { success: false, message: '获取 bdstoken 失败,Cookie 可能已过期' }; + + let workingCookie = cookie; + + try { + // Step 1: Get share info from page (shareid + uk) + const shareUrl = `https://pan.baidu.com/s/1${surl}`; + + // Verify password first if needed + if (pwd) { + const t = String(Date.now()); + const vUrl = `${API_HOST}/share/verify?surl=${surl}&bdstoken=${bdstoken}&t=${t}&channel=chunlei&web=1&clienttype=0`; + const vBody = new URLSearchParams({ pwd, vcode: '', vcode_str: '' }); + + const vRes = await fetch(vUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: vBody.toString(), + signal: AbortSignal.timeout(10000), + }); + + if (vRes.ok) { + const vData = await vRes.json() as any; + if (vData.errno === 0 && vData.randsk) { + workingCookie = updateCookie(workingCookie, 'BDCLND', vData.randsk); + } else { + return { success: false, message: `密码验证失败 errno=${vData.errno}` }; + } + } + } + + // Get share page to extract shareid + uk + const sRes = await fetch(shareUrl, { + headers: buildHeaders(workingCookie), + signal: AbortSignal.timeout(15000), + redirect: 'follow', + }); + if (!sRes.ok) return { success: false, message: `无法访问分享页面 HTTP ${sRes.status}` }; + + const html = await sRes.text(); + const shareidMatch = html.match(RE_SHAREID); + const ukMatch = html.match(RE_SHARE_UK); + + if (!shareidMatch || !ukMatch) { + return { success: false, message: '无法从页面提取分享信息' }; + } + + const shareid = shareidMatch[1]; + const uk = ukMatch[1]; + + // Step 2: Transfer + console.log(`[Baidu:Transfer] Transferring ${fsIds.length} file(s) to ${destPath}...`); + const fsidlist = `[${fsIds.join(',')}]`; + const path = destPath === '/' ? '/' : `/${destPath.replace(/^\//, '')}`; + + const tUrl = `${API_HOST}/share/transfer?shareid=${shareid}&from=${uk}&bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0`; + const tBody = new URLSearchParams({ fsidlist, path }); + + // Retry up to 3 times for transient fetch failures + let tRes: any; + let lastErr: any; + for (let attempt = 0; attempt < 3; attempt++) { + try { + tRes = await fetch(tUrl, { + method: 'POST', + headers: { ...buildHeaders(workingCookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tBody.toString(), + signal: AbortSignal.timeout(30000), + }); + break; + } catch (err: any) { + lastErr = err; + if (attempt < 2) { + console.log(`[Baidu:Transfer] Attempt ${attempt + 1} failed: ${err.message}, retrying in 2s...`); + await new Promise(r => setTimeout(r, 2000)); + } + } + } + if (!tRes) return { success: false, message: `转存网络错误: ${lastErr?.message || 'fetch failed'}` }; + + if (!tRes.ok) return { success: false, message: `转存请求失败 HTTP ${tRes.status}` }; + + const tData = await tRes.json() as any; + + if (tData.errno === 0) { + console.log(`[Baidu:Transfer] Success!`); + return { success: true, taskId: `transfer_${Date.now()}`, message: 'ok' }; + } + + // Known error codes + const errMap: Record = { + 0: '转存成功', + 2: '目标目录不存在', + 4: '目录中存在同名文件', + 12: '转存文件数超过限制', + 20: '容量不足', + '-4': '登录失效,请重新登录', + '-6': 'Cookie 无效,请重新获取', + '-62': '访问次数过多,请稍后再试', + // -33 is a known error, mapped as generic + }; + + const errMsg = errMap[tData.errno] || `errno=${tData.errno}`; + console.error(`[Baidu:Transfer] Failed: ${errMsg}`); + return { success: false, message: errMsg }; + + } catch (err: any) { + console.error('[Baidu:Transfer] Error:', err.message); + return { success: false, message: `转存异常: ${err.message}` }; + } + } + + // ═══════════════════════════════════ + // Main saveFromShare — full pipeline + // ═══════════════════════════════════ + + async saveFromShare(shareUrl: string, sourceTitle?: string): Promise<{ + success: boolean; + message: string; + shareUrl?: string; + sharePwd?: string; + folderName?: string; + taskId?: string; + fileCount?: number; + folderCount?: number; + originalFolderName?: string; + }> { + const parsed = extractShortUrl(shareUrl); + if (!parsed) return { success: false, message: '无法解析百度网盘链接格式' }; + + const { surl, pwd } = parsed; + console.log(`[Baidu] saveFromShare: surl=${surl}, pwd=${pwd ? '***' : '(none)'}`); + + // Step 1: Get share file list + const shareInfo = await this.getShareFiles(surl, pwd); + if (!shareInfo || shareInfo.files.length === 0) { + if ((this.config as any).cookieExpired) { + return { + success: false, + message: '百度登录已过期,请重新扫码登录', + cookieExpired: true, + } as any; + } + return { success: false, message: '获取分享文件列表失败,链接可能已过期或需要提取码' }; + } + + const { files } = shareInfo; + const originalFolderName = files[0]?.server_filename || ''; + const fileCount = files.filter(f => !f.isdir).length; + const folderCount = files.filter(f => f.isdir).length; + + // Step 2: Create/find daily folder, then sub-folder with original name + await humanDelay(); + const saveDirName = dailyFolderName(); + console.log(`[Baidu] Creating/finding dir: ${saveDirName}`); + const saveDirPath = await this.findOrCreateDir(saveDirName); + let destPath = saveDirPath || '/'; + if (!saveDirPath) { + console.log(`[Baidu] WARNING: failed to create dir, saving to root`); + } + + // Create sub-folder with original name under date folder + let savedFolderName = saveDirName; + if (originalFolderName && saveDirPath) { + const subDirName = originalFolderName.replace(/[/\\:*?"<>|]/g, '_').substring(0, 100); + const subDirPath = `${saveDirPath}/${subDirName}`; + const subOk = await this.createDir(subDirPath); + if (subOk) { + destPath = subDirPath; + savedFolderName = `${saveDirName}/${subDirName}`; + console.log(`[Baidu] Created sub-folder: ${subDirName}`); + } else { + console.log(`[Baidu] Failed to create sub-folder, saving to ${destPath}`); + } + } + + // Step 3: Transfer files + await humanDelay(); + const fsIds = files.map(f => f.fs_id); + console.log(`[Baidu] Transferring ${fsIds.length} file(s) to ${destPath}`); + const transferResult = await this.transferFiles(surl, pwd, fsIds, destPath); + if (!transferResult.success) { + return { success: false, message: `转存失败: ${transferResult.message}`, fileCount, folderCount, originalFolderName }; + } + + console.log(`[Baidu] Save complete: ${fsIds.length} files -> ${destPath}`); + + // Step 4: Create share link from user's own drive + let ownShareUrl = ''; + let ownSharePwd = ''; + let shareMsg = ''; + try { + // Find the saved directory to get its fs_id for sharing + const savedDir = await this.findDirByPath(destPath); + if (savedDir) { + const shareResult = await this.createShareLink(savedDir); + if (shareResult.success && shareResult.shareUrl) { + ownShareUrl = shareResult.shareUrl; + ownSharePwd = shareResult.sharePwd || ''; + shareMsg = '(已创建分享链接)'; + } else if (shareResult.needVerify) { + // Account needs verification to create shares + shareMsg = '(你的百度账号需要实名/绑定手机才能创建分享链接,当前为源链接)'; + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } else { + shareMsg = `(分享创建失败:${shareResult.message},当前为源链接)`; + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + } else { + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + } catch { + ownShareUrl = `https://pan.baidu.com/s/1${surl}`; + ownSharePwd = pwd || ''; + } + + return { + success: true, + message: `✅ 转存成功${shareMsg}`, + shareUrl: ownShareUrl || undefined, + sharePwd: ownSharePwd || undefined, + folderName: savedFolderName, + taskId: transferResult.taskId, + fileCount, + folderCount, + originalFolderName, + }; + } + + // ═══════════════════════════════════ + // Share creation (user's own drive) + // ═══════════════════════════════════ + + /** Find a directory by path, return its fs_id */ + private async findDirByPath(dirPath: string): Promise { + const cookie = this.getCookie(); + if (!cookie) return null; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return null; + + const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/')) || '/'; + const dirName = dirPath.substring(dirPath.lastIndexOf('/') + 1); + + try { + const res = await fetch( + `${API_HOST}/api/list?dir=${encodeURIComponent(parentPath)}&bdstoken=${bdstoken}&order=time&desc=1`, + { headers: buildHeaders(cookie), signal: AbortSignal.timeout(10000) } + ); + if (!res.ok) return null; + const data = await res.json() as any; + if (data.errno !== 0) return null; + const found = (data.list || []).find((f: any) => f.server_filename === dirName && f.isdir === 1); + return found ? String(found.fs_id) : null; + } catch { + return null; + } + } + + /** Create a share link from a file/directory in user's own drive */ + private async createShareLink(fsId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string; needVerify?: boolean }> { + const cookie = this.getCookie(); + if (!cookie) return { success: false, message: '未登录' }; + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return { success: false, message: '获取 bdstoken 失败' }; + + try { + // Generate a random 4-char share password (required by Baidu share/set API) + const pwd = Math.random().toString(36).substring(2, 6); + const body = new URLSearchParams({ + fid_list: `[${fsId}]`, + schannel: '0', + channel_list: '[]', + period: '0', + pwd, + }); + + const url = `${API_HOST}/share/set?bdstoken=${bdstoken}&channel=chunlei&web=1&clienttype=0&app_id=250528`; + const res = await fetch(url, { + method: 'POST', + headers: { ...buildHeaders(cookie), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(15000), + }); + + const data = await res.json() as any; + + if (data.errno === 0) { + // Success — extract shareid and link from response + const shareid = data.shareid; + if (shareid) { + const link = data.link || `https://pan.baidu.com/s/1${shareid}`; + return { success: true, shareUrl: link, sharePwd: pwd, message: 'ok' }; + } + return { success: false, message: '创建成功但未获取到分享链接' }; + } + + if (data.errno === 115) { + // Account genuinely restricted (should not happen with correct pwd param) + return { success: false, message: '账号异常,禁止分享', needVerify: true }; + } + + return { success: false, message: data.show_msg || `分享创建失败 errno=${data.errno}` }; + } catch (err: any) { + return { success: false, message: err.message || '网络错误' }; + } + } + + // ═══════════════════════════════════ + // Cleanup + // ═══════════════════════════════════ + + async emptyTrash(): Promise { + // Cookie-based approach: list recycle and delete + const cookie = this.getCookie(); + if (!cookie) return false; + + try { + const bdstoken = await this.getBdstoken(); + if (!bdstoken) return false; + + // We don't have a dedicated trash API with Cookie, use /api/list with recycle parameter + // For now, skip if trash is empty + console.log('[Baidu] emptyTrash: not fully implemented for Cookie yet, skipping'); + return true; + } catch (err: any) { + console.error('[Baidu] emptyTrash error:', err.message); + return false; + } + } + + async cleanupOldDateFolders(days: number): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + try { + const rootItems = await this.listRootDir(); + const oldFolders = rootItems.filter(item => { + if (!item.dir) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; + return item.file_name < cutoffStr; + }); + + if (oldFolders.length === 0) return { trashed: 0, errors: [] }; + + const fsIds = oldFolders.map(f => f.fid); + console.log(`[Baidu] Deleting ${fsIds.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`); + const ok = await this.deleteFiles(fsIds); + if (ok) return { trashed: fsIds.length, errors: [] }; + return { trashed: 0, errors: [`删除 ${fsIds.length} 个文件夹失败`] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } + } + + async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + try { + const info = await this.getUserInfo(); + if (!info || info.totalBytes <= 0) return { trashed: 0, errors: [] }; + + const usagePercent = (info.usedBytes / info.totalBytes) * 100; + if (usagePercent < thresholdPercent) { + console.log(`[Baidu] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`); + return { trashed: 0, errors: [] }; + } + + const targetBytesToFree = Math.floor(info.totalBytes * Math.min(deletePercent, 100) / 100); + const rootItems = await this.listRootDir(); + const dateFolders = rootItems + .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) + .sort((a, b) => a.file_name.localeCompare(b.file_name)); + + if (dateFolders.length === 0) return { trashed: 0, errors: [] }; + + const avgSize = info.usedBytes / dateFolders.length; + const estCount = Math.max(1, Math.ceil(targetBytesToFree / (avgSize || 1))); + const foldersToTrash = dateFolders.slice(0, Math.min(estCount, dateFolders.length)); + + const freedMB = (foldersToTrash.length * (avgSize || 0) / 1024 / 1024).toFixed(0); + const fsIdsToTrash = foldersToTrash.map(f => f.fid); + console.log(`[Baidu] Space threshold: deleting ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB)`); + + const ok = await this.deleteFiles(fsIdsToTrash); + if (ok) return { trashed: foldersToTrash.length, errors: [] }; + return { trashed: 0, errors: ['空间阈值清理失败'] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } + } +} + +// ═══════════════════════════════════ +// Utility: update Cookie string +// ═══════════════════════════════════ + +function updateCookie(cookieStr: string, key: string, value: string): string { + const pairs = cookieStr.split(';').map(s => s.trim()).filter(s => s); + let found = false; + const updated = pairs.map(p => { + const eq = p.indexOf('='); + if (eq > 0 && p.substring(0, eq) === key) { + found = true; + return `${key}=${value}`; + } + return p; + }); + if (!found) { + updated.push(`${key}=${value}`); + } + return updated.join('; '); +} diff --git a/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts b/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts new file mode 100644 index 0000000..9309a8c --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-ad-cleanup.ts @@ -0,0 +1,289 @@ +import { getSystemConfig } from "../../admin/system-config.service"; +import { getHeaders, makeQuery } from "./quark-api"; +import { listDir, listDirAllPages } from "./quark-api"; +import { humanDelay } from "./quark-api"; + +/** + * 广告关键词清理模块。 + * 在转存完成后执行: + * 1. 遍历转存的目录,删除文件名/文件夹名含广告关键词的内容 + * 2. 在转存根目录下创建警示文件夹(置顶提醒) + */ + +// ==================== 配置读取 ==================== + +/** 从 DB 读取广告关键词列表 */ +export function getAdKeywords(): string[] { + const raw = getSystemConfig("quark_ad_keywords") || ""; + return raw + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** 从 DB 读取警示文件夹名称列表 */ +export function getWarningFolderNames(): string[] { + const raw = getSystemConfig("quark_warning_folder_names") || ""; + return raw + .split("\n") + .map((s) => s.trim()) + .filter(Boolean); +} + +/** 从 DB 读取可疑文件后缀列表 */ +export function getSusExtensions(): string[] { + const raw = getSystemConfig("quark_sus_extensions") || ""; + if (raw.trim()) { + return raw + .split("\n") + .map((s) => s.trim().toLowerCase().replace(/^\./, "")) + .filter(Boolean); + } + // 默认可疑后缀 + return ["bat", "exe", "vbs", "scr", "cmd", "com", "pif", "js", "jar", "msi", "reg", "inf", "ps1"]; +} + +// ==================== 关键词检测 ==================== + +/** 检查文件名是否包含任意广告关键词 */ +export function containsAdKeyword( + fileName: string, + keywords: string[], +): boolean { + if (!keywords.length) return false; + const lower = fileName.toLowerCase(); + return keywords.some((kw) => kw && lower.includes(kw.toLowerCase())); +} + +// ==================== 删除操作 ==================== + +/** + * 遍历指定目录(含子目录),删除匹配广告关键词的文件和文件夹。 + * 返回删除的文件数。 + */ +export async function deleteAdFiles( + cookie: string, + dirFid: string, + keywords: string[], +): Promise { + if (!keywords.length) return 0; + + let deletedCount = 0; + const stack: string[] = [dirFid]; + const visited = new Set(); + + while (stack.length > 0) { + const fid = stack.pop()!; + if (visited.has(fid)) continue; + visited.add(fid); + + await humanDelay(); + const files = await listDir(cookie, fid); + if (!files || files.length === 0) continue; + + // 先收集所有需要删除的 fid + const toDelete: string[] = []; + const toKeep: string[] = []; + + const extensions = getSusExtensions(); + for (const file of files) { + const ext = file.file_name.split(".").pop()?.toLowerCase() || ""; + const isSusExt = extensions.includes(ext); + if (containsAdKeyword(file.file_name, keywords) || isSusExt) { + toDelete.push(file.fid); + console.log( + `[Quark-AdCleanup] 标记删除: "${file.file_name}" (fid: ${file.fid})${isSusExt ? " [可疑后缀]" : " [广告关键词]"}`, + ); + } else { + toKeep.push(file.fid); + // 如果是目录且不删除,继续遍历子目录 + if (file.dir) { + stack.push(file.fid); + } + } + } + + // 批量删除 + if (toDelete.length > 0) { + const deleteOk = await batchDeleteFiles(cookie, toDelete); + if (deleteOk) { + deletedCount += toDelete.length; + console.log( + `[Quark-AdCleanup] 已删除 ${toDelete.length} 个广告文件`, + ); + } + } + } + + return deletedCount; +} + +/** + * 批量删除文件/文件夹(移入回收站)。 + */ +async function batchDeleteFiles( + cookie: string, + fids: string[], +): Promise { + try { + const resp = await fetch( + `https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`, + { + method: "POST", + headers: { + ...getHeaders(cookie), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + action_type: 2, // 2 = 移入回收站 + file_list: fids.map((fid) => ({ fid })), + exclude_fids: [], + }), + signal: AbortSignal.timeout(15000), + }, + ); + const data = (await resp.json()) as any; + if (data.status === 200) { + return true; + } + console.log( + `[Quark-AdCleanup] batchDelete 返回非200: status=${data.status} msg=${data.message}`, + ); + return false; + } catch (err: any) { + console.log(`[Quark-AdCleanup] batchDelete 错误: ${err.message}`); + return false; + } +} + +// ==================== 警示文件夹创建 ==================== + +/** + * 在转存根目录下创建警示文件夹。 + * 文件夹名前加 ⚠️ 和空格,让其按字母排序置顶。 + * 已存在的则跳过。 + */ +export async function createWarningDirectories( + cookie: string, + dirNames: string[], +): Promise { + if (!dirNames.length) return; + + // 先获取根目录下所有文件夹,避免重复创建 + await humanDelay(); + const rootFiles = await listDirAllPages(cookie, "0"); + const existingDirs = new Set( + rootFiles.filter((f) => f.dir).map((f) => f.file_name), + ); + + for (const name of dirNames) { + // 格式化名称:确保以 ⚠️ 开头 + let formattedName = name; + if (!formattedName.startsWith("⚠️") && !formattedName.startsWith("⚠")) { + formattedName = `⚠️ ${formattedName}`; + } + // 去掉多余空格 + formattedName = formattedName.replace(/\s+/g, " ").trim(); + + if (existingDirs.has(formattedName)) { + console.log( + `[Quark-AdCleanup] 警示文件夹已存在,跳过: "${formattedName}"`, + ); + continue; + } + + await createSingleDir(cookie, formattedName); + // 加入已存在集合,防止同名重试 + existingDirs.add(formattedName); + } +} + +/** + * 创建单个文件夹。 + */ +async function createSingleDir( + cookie: string, + dirName: string, +): Promise { + try { + const resp = await fetch( + `https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`, + { + method: "POST", + headers: { + ...getHeaders(cookie), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pdir_fid: "0", + file_name: dirName, + dir: true, + dir_path: "", + }), + signal: AbortSignal.timeout(10000), + }, + ); + const data = (await resp.json()) as any; + if (data.status === 200 && data.data?.fid) { + console.log( + `[Quark-AdCleanup] 已创建警示文件夹: "${dirName}" (fid: ${data.data.fid})`, + ); + return true; + } + console.log( + `[Quark-AdCleanup] 创建文件夹失败: status=${data.status} msg=${data.message}`, + ); + return false; + } catch (err: any) { + console.log( + `[Quark-AdCleanup] 创建文件夹错误: "${dirName}" — ${err.message}`, + ); + return false; + } +} + +// ==================== 主入口 ==================== + +/** + * 执行广告清理 + 创建警示文件夹。 + * 在转存重命名后调用。 + */ +export async function runAdCleanup( + cookie: string, + savedDirFid: string, +): Promise<{ adDeleted: number; warningDirs: number }> { + const keywords = getAdKeywords(); + const warningNames = getWarningFolderNames(); + + let adDeleted = 0; + let warningDirs = 0; + + // 1. 广告关键词清理 + if (keywords.length > 0) { + console.log( + `[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`, + ); + adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords); + console.log( + `[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`, + ); + } else { + console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理"); + } + + // 2. 创建警示文件夹 + if (warningNames.length > 0) { + console.log( + `[Quark-AdCleanup] 开始创建警示文件夹: ${warningNames.length} 个`, + ); + await createWarningDirectories(cookie, warningNames); + warningDirs = warningNames.length; + console.log( + `[Quark-AdCleanup] 警示文件夹创建完成(共 ${warningDirs} 个)`, + ); + } else { + console.log("[Quark-AdCleanup] 无警示文件夹配置,跳过创建"); + } + + return { adDeleted, warningDirs }; +} \ No newline at end of file diff --git a/packages/backend/src/cloud/drivers/quark-api.ts b/packages/backend/src/cloud/drivers/quark-api.ts new file mode 100644 index 0000000..1aff37f --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-api.ts @@ -0,0 +1,237 @@ +// Native fetch available in Node 20+ +import * as crypto from 'crypto'; + +/** + * HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。 + * 所有模块共用此单例/函数集,不持有状态。 + */ + +export interface QuarkConfig { + cookie: string; + nickname?: string; +} + +// ==================== Headers & Params ==================== + +const BASE_URL = 'https://drive-pc.quark.cn'; + +export function getHeaders(cookie: string): Record { + return { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Cookie': cookie, + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Referer': 'https://pan.quark.cn/', + 'Origin': 'https://pan.quark.cn', + }; +} + +export function getCommonParams(): Record { + return { pr: 'ucpro', fr: 'pc' }; +} + +/** Generate query string with common params + random timing to mimic browser */ +export function makeQuery(extra: Record = {}): string { + const __dt = Math.floor(Math.random() * 240000 + 60000); + const __t = Date.now() / 1000; + return new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + app: 'clouddrive', + __dt: String(__dt), + __t: String(__t), + ...extra, + }).toString(); +} + +/** Random delay to mimic human behavior (500-2000ms) */ +export async function humanDelay(): Promise { + const ms = Math.floor(Math.random() * 1500) + 500; + await new Promise(r => setTimeout(r, ms)); +} + +/** Generate a random password for share links */ +export function randomSharePwd(): string { + return Math.floor(1000 + Math.random() * 9000).toString(); +} + +/** + * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix). + */ +export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } { + // Match both __kps and kps (with or without __ prefix) + const kpsMatch = cookie.match(/__?kps=([a-zA-Z0-9%+/=]+)/); + const signMatch = cookie.match(/__?sign=([a-zA-Z0-9%+/=]+)/); + const vcodeMatch = cookie.match(/__?vcode=([a-zA-Z0-9%+/=]+)/); + if (kpsMatch && signMatch && vcodeMatch) { + return { + kps: kpsMatch[1], + sign: signMatch[1].replace(/%25/g, '%'), + vcode: vcodeMatch[1], + }; + } + return {}; +} + +// ==================== Shared fetch helpers ==================== + +/** + * Raw fetch wrapper with JSON parse + status check. + * Returns parsed JSON body on 2xx, null on network error. + */ +export async function apiFetch( + path: string, + options: { + method?: string; + query?: Record; + body?: any; + cookie: string; + timeout?: number; + }, +): Promise { + const { method = 'GET', query, body, cookie, timeout = 10000 } = options; + let url = `${BASE_URL}${path}`; + if (query) url += `?${new URLSearchParams(query).toString()}`; + try { + const resp = await fetch(url, { + method, + headers: { + ...getHeaders(cookie), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }); + if (!resp.ok) return null; + return (await resp.json()) as T; + } catch { + return null; + } +} + +// ==================== File listing (shared across modules) ==================== + +export interface QuarkFile { + fid: string; + file_name: string; + share_fid_token?: string; + dir: boolean; + size?: number; +} + +/** + * List files in a directory by FID. + */ +export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise { + try { + const params = new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + pdir_fid: pdirFid, + _page: String(page), + _size: String(pageSize), + _fetch_total: '1', + _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200) return []; + return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: '', + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } +} + +/** + * List root directory (pdir_fid=0) — returns all top-level dirs/files. + */ +export async function listRootDir(cookie: string): Promise { + try { + const params = new URLSearchParams({ + pr: 'ucpro', fr: 'pc', + pdir_fid: '0', + _page: '1', _size: '200', + _fetch_total: '1', _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200 || !data.data?.list) return []; + return (data.data.list || []).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } +} + +/** + * List all files in a directory, handling pagination. + * Fetches all pages until no more results. + */ +export async function listDirAllPages(cookie: string, pdirFid: string): Promise { + const allFiles: QuarkFile[] = []; + let page = 1; + const pageSize = 100; + let total = -1; + while (total === -1 || (page - 1) * pageSize < total) { + const files = await listDir(cookie, pdirFid, page, pageSize); + if (!files.length) break; + allFiles.push(...files); + if (total === -1) { + total = files.length; + } + page++; + } + return allFiles; +} + +// ==================== Format utilities ==================== + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */ +export function dailyFolderName(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** Generate a random folder name for saving (fallback) */ +export function randomFolderName(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let name = ''; + for (let i = 0; i < 12; i++) { + name += chars[Math.floor(Math.random() * chars.length)]; + } + return name; +} diff --git a/packages/backend/src/cloud/drivers/quark-auth.ts b/packages/backend/src/cloud/drivers/quark-auth.ts new file mode 100644 index 0000000..5c5fabd --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-auth.ts @@ -0,0 +1,60 @@ +import { QuarkConfig } from './quark-api'; +import { getHeaders, getMparam, apiFetch, makeQuery } from './quark-api'; + +/** + * 认证模块 — Cookie 验证、账号信息获取、QR 登录状态检查。 + * 所有方法以 cookie 字符串为参数,不持有驱动状态。 + */ + +// ==================== Validate ==================== + +/** + * Validate the cookie by fetching user info. + */ +export async function validate(cookie: string): Promise { + const MAX_RETRIES = 2; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + // Use account/info API (same as quark-auto-save project) + // Only needs __uid cookie, no mparam (kps/sign/vcode) required + const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc'; + const response = await fetch(url, { + headers: { + ...getHeaders(cookie), + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch', + }, + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) return false; + const data = await response.json() as any; + if (data?.data?.nickname) return true; + } catch (err: any) { + if (attempt < MAX_RETRIES) { + console.log(`[Quark] validate attempt ${attempt + 1} failed: ${err.message}, retrying...`); + await new Promise(r => setTimeout(r, 2000)); + continue; + } + console.log(`[Quark] validate all ${MAX_RETRIES + 1} attempts failed: ${err.message}`); + } + } + return false; +} + +/** Fetch nickname from Quark account info (same API used by quark-auto-save) */ +export async function fetchNickname(cookie: string): Promise { + try { + const url = 'https://pan.quark.cn/account/info?fr=pc&platform=pc'; + const response = await fetch(url, { + headers: { + ...getHeaders(cookie), + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch', + }, + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) return null; + const data = await response.json() as any; + return data?.data?.nickname || null; + } catch { + return null; + } +} diff --git a/packages/backend/src/cloud/drivers/quark-cleanup.ts b/packages/backend/src/cloud/drivers/quark-cleanup.ts new file mode 100644 index 0000000..5b149cd --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-cleanup.ts @@ -0,0 +1,315 @@ +import { getHeaders, getCommonParams, getMparam, listRootDir, listDirAllPages, formatBytes, humanDelay, makeQuery, listDir, QuarkFile } from './quark-api'; + +/** + * 容量信息 & 空间清理模块。 + */ +const BASE_URL = 'https://drive-pc.quark.cn'; + +// ==================== Storage Info ==================== + +/** Cached used space, keyed by hour block (3h window) */ +const cachedUsedSpace: { bytes: number; hourBlock: number } | null = null; + +// We use a function-scoped cache instead of instance field +const storageCache: { bytes: number; hourBlock: number } = { bytes: 0, hourBlock: -1 }; + +/** + * Get total capacity from /capacity/detail API. + * Also does a quick used-space estimate by summing root-level file sizes + subdir sizes + * (夸克目录的 size 字段 = 该目录内所有文件总大小,无需递归). + * If the API fails (e.g. missing sign params), falls back to fallbackTotal if provided. + */ +export async function getStorageInfoQuick(cookie: string, fallbackTotal?: string): Promise<{ total: string; totalBytes: number; used: string; usedBytes: number }> { + try { + const mparam = getMparam(cookie); + const params = new URLSearchParams({ + ...getCommonParams(), + kps: mparam.kps || '', + sign: mparam.sign || '', + vcode: mparam.vcode || '', + }); + const capResponse = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + let totalBytes = 0; + if (capResponse.ok) { + const data = await capResponse.json() as any; + if (data.status === 200 && data.data) { + totalBytes = data.data.capacity_summary?.sum_capacity || 0; + if (totalBytes === 0) { + const memberships = [...(data.data.effect || []), ...(data.data.expired || [])]; + totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0); + } + } + } + + // Quick used-space estimate: sum root-level file sizes + subdir sizes + let usedBytes = 0; + try { + const rootFiles = await listRootDir(cookie); + for (const f of rootFiles) { + usedBytes += f.size || 0; + } + } catch {} + + // Cache the result (3h window) + const currentHourBlock = Math.floor(new Date().getHours() / 3); + storageCache.bytes = usedBytes; + storageCache.hourBlock = currentHourBlock; + + if (totalBytes > 0) { + return { + total: formatBytes(totalBytes), + totalBytes, + used: formatBytes(usedBytes), + usedBytes, + }; + } + } catch {} + + // Fallback: try to parse from a human-readable string like "6 TB" + if (fallbackTotal) { + const match = fallbackTotal.match(/^([\d.]+)\s*([KMGT]B?)/i); + if (match) { + const num = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + const multipliers: Record = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4, PB: 1024 ** 5 }; + const multiplier = multipliers[unit] || multipliers[unit.replace('B', '') + 'B'] || 0; + if (multiplier > 0) { + return { total: fallbackTotal, totalBytes: Math.round(num * multiplier), used: '-', usedBytes: 0 }; + } + } + } + return { total: '-', totalBytes: 0, used: '-', usedBytes: 0 }; +} + +/** + * Get storage info with used space calculation. + */ +export async function getStorageInfo(cookie: string): Promise<{ used: string; total: string; usedBytes: number; totalBytes: number }> { + try { + const mparam = getMparam(cookie); + let totalBytes = 0; + const params = new URLSearchParams({ + ...getCommonParams(), + kps: mparam.kps || '', + sign: mparam.sign || '', + vcode: mparam.vcode || '', + }); + const response = await fetch(`${BASE_URL}/1/clouddrive/capacity/detail?${params.toString()}`, { + headers: getHeaders(cookie), + signal: AbortSignal.timeout(10000), + }); + if (response.ok) { + const data = await response.json() as any; + if (data.status === 200 && data.data) { + totalBytes = data.data.capacity_summary?.sum_capacity || 0; + if (totalBytes === 0) { + const memberships = [...(data.data.effect || []), ...(data.data.expired || [])]; + totalBytes = memberships.reduce((max: number, m: any) => Math.max(max, m.capacity || 0), 0); + } + } + } + + const usedBytes = await calculateUsedSpace(cookie); + + if (totalBytes > 0 || usedBytes > 0) { + return { + total: totalBytes > 0 ? formatBytes(totalBytes) : '-', + used: formatBytes(usedBytes), + usedBytes, + totalBytes: totalBytes > 0 ? totalBytes : 0, + }; + } + return { used: '0 B', total: '-', usedBytes: 0, totalBytes: 0 }; + } catch { + return { used: '-', total: '-', usedBytes: 0, totalBytes: 0 }; + } +} + +/** + * Calculate total used space by recursively traversing all files + * and summing their sizes. Uses 3-hour time window cache. + */ +export async function calculateUsedSpace(cookie: string): Promise { + const currentHourBlock = Math.floor(new Date().getHours() / 3); + if (storageCache.hourBlock === currentHourBlock && storageCache.bytes > 0) { + return storageCache.bytes; + } + let totalUsed = 0; + const stack: string[] = ['0']; + const visited = new Set(); + while (stack.length > 0) { + const fid = stack.pop()!; + if (visited.has(fid)) continue; + visited.add(fid); + const files = await listDirAllPages(cookie, fid); + if (!files.length) continue; + for (const f of files) { + if (f.dir) { + stack.push(f.fid); + } else { + totalUsed += f.size || 0; + } + } + await new Promise(r => setTimeout(r, 50)); + } + storageCache.bytes = totalUsed; + storageCache.hourBlock = currentHourBlock; + return totalUsed; +} + +// ==================== Cleanup ==================== + +/** + * Trash specified files/folders (move to recycle bin). + */ +export async function trashFiles(cookie: string, fids: string[]): Promise { + if (!fids.length) return true; + try { + const response = await fetch( + `${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action_type: 1, // 1 = move to trash + filelist: fids, + exclude_filelist: [], + }), + signal: AbortSignal.timeout(30000), + }, + ); + if (!response.ok) return false; + const data = await response.json() as any; + if (data.status === 200) return true; + console.error(`[Quark] trashFiles failed: ${data.message || data.status}`); + return false; + } catch (err: any) { + console.error(`[Quark] trashFiles error: ${err.message}`); + return false; + } +} + +/** + * Empty the recycle bin — permanently delete all files in trash. + */ +export async function emptyTrash(cookie: string): Promise { + try { + const response = await fetch( + `${BASE_URL}/1/clouddrive/file/trash/clear?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(60000), + }, + ); + if (!response.ok) return false; + const data = await response.json() as any; + if (data.status === 200) return true; + console.error(`[Quark] emptyTrash failed: ${data.message || data.status}`); + return false; + } catch (err: any) { + console.error(`[Quark] emptyTrash error: ${err.message}`); + return false; + } +} + +/** + * Cleanup: trash date-named folders (YYYY-MM-DD) older than `days`. + */ +export async function cleanupOldDateFolders(cookie: string, days: number): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + + try { + const rootItems = await listRootDir(cookie); + const oldFolders = rootItems.filter(item => { + if (!item.dir) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) return false; + return item.file_name < cutoffStr; + }); + + if (oldFolders.length === 0) { + return { trashed: 0, errors: [] }; + } + + const fids = oldFolders.map(f => f.fid); + console.log(`[Quark] Trashing ${fids.length} old date folders (before ${cutoffStr}): ${oldFolders.map(f => f.file_name).join(', ')}`); + const ok = await trashFiles(cookie, fids); + if (ok) { + return { trashed: fids.length, errors: [] }; + } + return { trashed: 0, errors: [`Trash API returned failure for ${fids.length} folders`] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } +} + +/** + * Cleanup: if used space exceeds thresholdPercent% of total, + * delete the oldest date folders until totalBytes * deletePercent/100 + * of total capacity is freed. + */ +export async function cleanupBySpaceThreshold( + cookie: string, + thresholdPercent: number, + deletePercent: number, +): Promise<{ trashed: number; errors: string[] }> { + const errors: string[] = []; + + try { + const storage = await getStorageInfo(cookie); + if (storage.totalBytes <= 0) return { trashed: 0, errors: [] }; + + const usagePercent = (storage.usedBytes / storage.totalBytes) * 100; + if (usagePercent < thresholdPercent) { + console.log(`[Quark] Usage ${usagePercent.toFixed(1)}% below threshold ${thresholdPercent}%, skipping`); + return { trashed: 0, errors: [] }; + } + + const targetBytesToFree = Math.floor(storage.totalBytes * Math.min(deletePercent, 100) / 100); + + const rootItems = await listRootDir(cookie); + const dateFolders = rootItems + .filter(item => item.dir && /^\d{4}-\d{2}-\d{2}$/.test(item.file_name)) + .sort((a, b) => a.file_name.localeCompare(b.file_name)); + + if (dateFolders.length === 0) return { trashed: 0, errors: [] }; + + const hasSizes = dateFolders.some(f => f.size && f.size > 0); + let cumulativeSize = 0; + const foldersToTrash: typeof dateFolders = []; + + if (hasSizes) { + for (const folder of dateFolders) { + foldersToTrash.push(folder); + cumulativeSize += folder.size || 0; + if (cumulativeSize >= targetBytesToFree) break; + } + } else { + const avgSizePerFolder = storage.usedBytes / dateFolders.length; + const estCount = Math.max(1, Math.ceil(targetBytesToFree / avgSizePerFolder)); + foldersToTrash.push(...dateFolders.slice(0, estCount)); + cumulativeSize = estCount * avgSizePerFolder; + } + + const freedMB = (cumulativeSize / 1024 / 1024).toFixed(0); + const targetMB = (targetBytesToFree / 1024 / 1024).toFixed(0); + const fidsToTrash = foldersToTrash.map(f => f.fid); + console.log(`[Quark] Space threshold: trashing ${foldersToTrash.length}/${dateFolders.length} oldest folders (~${freedMB} MB) to free ${targetMB} MB (${deletePercent}% of ${(storage.totalBytes/1024/1024/1024).toFixed(0)} GB total)`); + + const ok = await trashFiles(cookie, fidsToTrash); + if (ok) { + console.log(`[Quark] ✅ Space-threshold trashed ${foldersToTrash.length} folders (~${freedMB} MB)`); + return { trashed: foldersToTrash.length, errors: [] }; + } + return { trashed: 0, errors: [`Space-threshold trash failed for ${foldersToTrash.length} folders`] }; + } catch (err: any) { + return { trashed: 0, errors: [err.message] }; + } +} diff --git a/packages/backend/src/cloud/drivers/quark-rename.ts b/packages/backend/src/cloud/drivers/quark-rename.ts new file mode 100644 index 0000000..49d87f9 --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-rename.ts @@ -0,0 +1,259 @@ +import * as crypto from 'crypto'; + +/** + * 防和谐重命名模块。 + * 对文件名/目录名执行谐音替换 + 可读标签保留(集数、画质、语言等)。 + */ + +// ==================== Homophone Map ==================== + +const HOMOPHONE_MAP: Record = { + // 网盘热门番名 — 谐音替换 (same sound, different char) + '斗':'陡','破':'坡','苍':'仓','穹':'穷', + '完':'玩','美':'每','世':'士','界':'介', + '凡':'烦','人':'仁','修':'休','罗':'络', + '仙':'先','逆':'腻','遮':'折','天':'添', + '吞':'屯','噬':'逝','大':'达','主':'嘱','宰':'崽', + '星':'惺','辰':'晨','变':'便','一':'伊','念':'捻', + '永':'泳','恒':'横','神':'申','墓':'暮','长':'尝','生':'甥', + '剑':'箭','来':'莱','诡':'鬼','秘':'蜜', + '全':'泉','职':'值','盘':'磐','龙':'笼', + '雪':'血','鹰':'莺','莽':'蟒','荒':'慌','纪':'记', + '珠':'株','王':'亡','座':'坐','牧':'木','记':'计', + '沧':'舱','元':'圆','图':'涂','紫':'仔','川':'串', + '百':'白','炼':'恋','成':'程','饶':'绕','命':'冥', + // 通用谐音替换 + '的':'得','了':'啦','是':'事','不':'布','我':'窝', + '你':'尼','他':'她','有':'友','和':'合','与':'予', + '上':'尚','下':'夏','中':'忠','第':'弟','集':'级', + '话':'划','季':'际','年':'念','月':'阅','日':'曰', + '新':'心','版':'板','高':'糕','清':'青','原':'源', + '小':'晓','片':'篇','视':'市','频':'贫','道':'到', + '动':'洞','画':'话','声':'升','音':'因','文':'闻', + '明':'名','暗':'黯','光':'广','影':'映','色':'瑟', + '风':'疯','雨':'语','花':'华','国':'果','家':'佳', + '战':'站','争':'挣','士':'仕','兵':'宾', + '皇':'惶','帝':'谛','魔':'磨','鬼':'诡','怪':'乖', + '精':'经','灵':'铃','妖':'夭','武':'舞','侠':'狭', + '杀':'刹','血':'雪','刀':'叨','枪':'呛','炮':'泡', + '时':'石','空':'孔','前':'钱','后':'厚','东':'冬', + '南':'难','西':'夕','北':'备','开':'凯','关':'官', + '出':'初','进':'近','去':'趣', + '短':'短','多':'多','少':'少','真':'贞','假':'价', + '好':'郝','坏':'怀','对':'队','错':'措','以':'已', + '从':'从','被':'被','把':'把','将':'将','在':'在', + '但':'但','就':'就','才':'才','也':'也','很':'狠', + '又':'又','再':'再','更':'更','最':'最','总':'总', + '共':'共','只':'只','各':'各','每':'每','任':'任', + '所':'所','该':'该','本':'本', +}; + +const NOISE_CJK = '的了在是不有会可对所之也同与及但或如且乃而岂乎焉兮哉亦犹尚乃其若故盖诸焉欤' + + '么个着过把对为从以到说时要就这那和上人家下能出得发来年心开物力些长样吧啊哦嗯嚯哇咯呗哟嘿呵哈'; + +// ==================== Helpers ==================== + +/** Convert Chinese text to homophonic (substitute chars with same sound) */ +function homophonicText(text: string): string { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = HOMOPHONE_MAP[ch]; + result += homophone || ch; + } else { + result += ch; + } + } + return result; +} + +/** Convert Chinese text to pinyin-initial-like string (each char → first pinyin letter or fallback) */ +function pinyinLike(text: string): string { + let result = ''; + for (const ch of text) { + if (/[\u4e00-\u9fff]/.test(ch)) { + const homophone = HOMOPHONE_MAP[ch]; + if (homophone) { + result += pinyinInitial(homophone); + } else { + const code = ch.charCodeAt(0); + result += String.fromCharCode(97 + (code % 26)); + } + } else if (/[a-zA-Z0-9]/.test(ch)) { + result += ch; + } else if (/[\s._-]/.test(ch)) { + result += '_'; + } + } + return result.replace(/_+/g, '_').replace(/^_|_$/g, ''); +} + +/** Get pinyin initial (first letter of pinyin) for a Chinese character */ +function pinyinInitial(ch: string): string { + const code = ch.charCodeAt(0); + if (code >= 0x4E00 && code <= 0x9FFF) { + const initials = ['b','p','m','f','d','t','n','l','g','k','h','j','q','x','zh','ch','sh','r','z','c','s','y','w']; + const idx = Math.min(Math.floor((code - 0x4E00) / 700), initials.length - 1); + return initials[idx]; + } + return ch.toLowerCase(); +} + +// ==================== Public API ==================== + +/** + * Anti-harmony rename for directories. + * 80%: light homophonic replacement, 20%: partial pinyin. + */ +export function magicRenameDir(dirName: string): string { + const hash = crypto.createHash('md5').update(dirName + Date.now()).digest('hex').slice(0, 4); + + let cleanName = dirName.trim().replace(/\s+/g, ' '); + if (!cleanName) { + return `media_${hash}`; + } + + let baseName: string; + + if (Math.random() < 0.2) { + // Partial pinyin: 30% of CJK chars → pinyin initial, 70% stay as-is + const chars = [...cleanName]; + const result: string[] = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch) && Math.random() < 0.3) { + result.push(pinyinInitial(ch)); + } else { + result.push(ch); + } + } + baseName = result.join(''); + } else { + // Light homophonic: replace each CJK char, keep everything else as-is + const chars = [...cleanName]; + const result: string[] = []; + for (const ch of chars) { + if (/[\u4e00-\u9fff]/.test(ch)) { + result.push(HOMOPHONE_MAP[ch] || ch); + } else { + result.push(ch); + } + } + baseName = result.join(''); + + // Optional: insert 0-2 light noise chars (low probability) + const noiseCount = Math.random() < 0.3 ? (Math.random() < 0.5 ? 1 : 2) : 0; + for (let n = 0; n < noiseCount; n++) { + const pos = Math.floor(Math.random() * (baseName.length + 1)); + const ink = NOISE_CJK[Math.floor(Math.random() * NOISE_CJK.length)]; + baseName = baseName.slice(0, pos) + ink + baseName.slice(pos); + } + } + + baseName = baseName.replace(/[^\u4e00-\u9fff\w]/g, '_'); + baseName = baseName.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (baseName.length > 30) baseName = baseName.slice(0, 30); + + return `${baseName}_${hash}`; +} + +/** + * Anti-harmony rename for files. + * KEEPS: episode numbers, quality, language tags, original extension. + * REPLACES: Chinese title with homophonic/pinyin. + */ +export function magicRename(filename: string): string { + const hash = crypto.createHash('md5').update(filename + Date.now()).digest('hex').slice(0, 8); + + let ext = ''; + const extMatch = filename.match(/\.[a-zA-Z0-9]+$/); + if (extMatch) { + ext = extMatch[0]; + filename = filename.slice(0, -ext.length); + } + + // Extract and REMEMBER: episode info, quality, language, year + const episodePatterns = [ + { regex: /第\s*(\d+)\s*[集话話話話话回章期]/, format: (m: string) => 'Ep' + m.replace(/[^\d]/g, '') }, + { regex: /Ep\d+|ep\d+/i, format: (m: string) => m.toUpperCase() }, + { regex: /Part\s*\d+/i, format: (m: string) => m.replace(/\s+/g, '') }, + { regex: /E\d{2,}/i, format: (m: string) => m.toUpperCase() }, + ]; + let episodeTag = ''; + for (const { regex, format } of episodePatterns) { + const m = filename.match(regex); + if (m) { + episodeTag = format(m[0]); + filename = filename.replace(m[0], ''); + break; + } + } + + // Extract and REMEMBER: quality tags + const qualityPattern = /\b(4k|1080p|1080P|2160p|720p|HD|BluRay|Blu-ray|HDR|WEB-DL|WEBRip|BDRip|REMUX|DV|Dovi|HEVC|x264|x265|H\.264|H\.265)\b/; + const qualityMatch = filename.match(qualityPattern); + const qualityTag = qualityMatch ? qualityMatch[0] : ''; + if (qualityMatch) filename = filename.replace(qualityMatch[0], ''); + + // Extract and REMEMBER: language tags + const langPattern = /\b(CHS|CHT|JP|EN|BIG5|GB|粤语|国语|日语|英语|中字|日字|英字|繁体中字)\b/; + const langMatch = filename.match(langPattern); + const langTag = langMatch ? langMatch[0] : ''; + if (langMatch) filename = filename.replace(langMatch[0], ''); + + // Extract and REMEMBER: year + const yearMatch = filename.match(/\b(20\d{2})\b/); + const yearTag = yearMatch ? yearMatch[0] : ''; + if (yearMatch) filename = filename.replace(yearMatch[0], ''); + + // Extract and REMEMBER: season info + const seasonMatch = filename.match(/第?\s*(\d+)\s*[季部期]/); + const seasonTag = seasonMatch ? `${seasonMatch[1]}季` : ''; + if (seasonMatch) filename = filename.replace(seasonMatch[0], ''); + + // Now process the remaining name (mostly Chinese title) + filename = filename.replace(/[._\-【】\[\]()()\s]+/g, '_').trim(); + + const useHomophonic = Math.random() > 0.5; + let titlePart: string; + if (useHomophonic) { + titlePart = homophonicText(filename); + titlePart = titlePart.replace(/[^\u4e00-\u9fff\wa-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); + } else { + titlePart = pinyinLike(filename); + titlePart = titlePart.replace(/[^a-zA-Z0-9]/g, '_'); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + if (titlePart.length > 15) titlePart = titlePart.slice(0, 15); + } + + // Remove sensitive keywords from title part + const sensitiveWords = /斗破|完美|凡人|仙逆|遮天|吞噬|大主宰|绝世|武动|星辰变|一念永恒|修罗|神墓|长生|剑来|诡秘|全职|斗罗|盘龙|雪鹰|莽荒纪|天珠变|神印王座|牧神记|沧元图|紫川|百炼成神|大王饶命|全球高考/ig; + titlePart = titlePart.replace(sensitiveWords, ''); + titlePart = titlePart.replace(/_+/g, '_').replace(/^_|_$/g, ''); + + // Build preserved tags + const tags: string[] = []; + if (seasonTag) tags.push(seasonTag); + if (episodeTag) tags.push(episodeTag); + if (qualityTag) tags.push(qualityTag.toUpperCase()); + if (langTag) tags.push(langTag); + if (yearTag) tags.push(yearTag); + tags.push(hash); // Always add hash for uniqueness + + const newExt = ext || '.bin'; + + const parts = [titlePart, ...tags].filter(Boolean); + let result = parts.join('_'); + + if (result.length > 80) { + result = result.slice(0, 80); + } + + if (result.length < 10) { + const filler = crypto.randomBytes(4).toString('hex'); + result = `${filler}_${result}`; + } + + return result + newExt; +} diff --git a/packages/backend/src/cloud/drivers/quark-share.ts b/packages/backend/src/cloud/drivers/quark-share.ts new file mode 100644 index 0000000..50e2e5f --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-share.ts @@ -0,0 +1,409 @@ +import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, randomSharePwd, apiFetch, QuarkFile } from './quark-api'; + +/** + * 分享模块 — 分享链接解析、转存任务、创建分享链接。 + */ + +const BASE_URL = 'https://drive-pc.quark.cn'; + +// ==================== Acquire Stoken ==================== + +/** + * Acquire stoken for a share link (needed for detail/save). + */ +export async function acquireStoken(cookie: string, pwdId: string): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const params = new URLSearchParams(getCommonParams()); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/share/sharepage/token?${params.toString()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ pwd_id: pwdId, passcode: '' }), + signal: AbortSignal.timeout(10000), + }, + ); + if (!resp.ok) { + if (attempt < 2) continue; + return null; + } + const data = await resp.json() as any; + if (data.status === 200 && data.data?.stoken) { + return data.data.stoken; + } + return null; + } catch { + if (attempt >= 2) return null; + await new Promise(r => setTimeout(r, 500 * (attempt + 1))); + } + } + return null; +} + +// ==================== Get Share Files ==================== + +/** + * Fetch detail at a given pdir_fid within a share. + */ +export async function getDetailAt( + cookie: string, + pwdId: string, + stoken: string, + pdirFid: string, +): Promise { + const params = new URLSearchParams({ + ...getCommonParams(), + pwd_id: pwdId, + stoken, + pdir_fid: pdirFid, + force: '0', + _page: '1', + _size: '50', + _fetch_banner: '0', + _fetch_share: '1', + _fetch_total: '1', + _sort: 'file_type:asc,updated_at:desc', + ver: '2', + fetch_share_full_path: '0', + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/share/sharepage/detail?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200) return []; + return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: f.share_fid_token || '', + dir: f.dir || false, + size: f.size || 0, + })); +} + +/** + * Recursively collect files from a share. + * If the share contains a single directory, drill into it to list contents + * but still save the directory itself. + */ +export async function getShareFiles( + cookie: string, + pwdId: string, + stoken: string, +): Promise<{ files: QuarkFile[]; topDir: boolean; childFiles?: QuarkFile[] } | null> { + try { + const topLevel = await getDetailAt(cookie, pwdId, stoken, '0'); + if (!topLevel || topLevel.length === 0) return null; + + // If the share is a single directory, we save the directory itself + // and fetch its contents for renaming later + if (topLevel.length === 1 && topLevel[0].dir) { + const innerFiles = await getDetailAt(cookie, pwdId, stoken, topLevel[0].fid); + return { + files: topLevel, + topDir: true, + childFiles: innerFiles || [], + }; + } + + // Multiple top-level items: save them directly + return { + files: topLevel, + topDir: false, + }; + } catch { + return null; + } +} + +// ==================== Save Files (share → cloud) ==================== + +/** + * Save shared files to the user's cloud directory. + */ +export async function saveFiles( + cookie: string, + pwdId: string, + stoken: string, + fids: string[], + fidTokens: string[], + toPdirFid: string, +): Promise<{ success: boolean; message: string; taskId?: string }> { + try { + const resp = await fetch( + `${BASE_URL}/1/clouddrive/share/sharepage/save?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fid_list: fids, + fid_token_list: fidTokens, + to_pdir_fid: toPdirFid, + pwd_id: pwdId, + stoken, + pdir_fid: '0', + scene: 'link', + }), + signal: AbortSignal.timeout(30000), + }, + ); + const data = await resp.json() as any; + if (data.status === 200 && data.data?.task_id) { + return { success: true, message: 'Save task created', taskId: data.data.task_id }; + } + return { + success: false, + message: data.message === 'require login [guest]' + ? '夸克网盘 Cookie 已过期,请在后台重新配置 Cookie' + : (data.message || `API 返回错误 (status=${data.status}, code=${data.code})`), + }; + } catch (err: any) { + return { success: false, message: err.message || 'Network error' }; + } +} + +// ==================== Wait for Save Task ==================== + +/** + * Poll task status until complete or timeout. + * Returns the saved file FIDs (save_as_top_fids). + */ +export async function waitForTask(cookie: string, taskId: string, timeoutMs: number): Promise { + const start = Date.now(); + let retryIndex = 0; + + while (Date.now() - start < timeoutMs) { + try { + const params = new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + task_id: taskId, + retry_index: String(retryIndex), + __dt: String(Math.floor(Math.random() * 240000 + 60000)), + __t: String(Date.now() / 1000), + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/task?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) }, + ); + const data = await resp.json() as any; + if (data.status === 200) { + if (data.data?.status === 2) { + // Task completed + const savedFids: string[] = data.data?.save_as?.save_as_top_fids || []; + return savedFids; + } + // Still in progress + retryIndex++; + } + } catch { + // Network error, retry + } + await new Promise(r => setTimeout(r, 1000)); + } + return null; // Timeout +} + +// ==================== Rename File ==================== + +/** + * Rename a file by its FID. + */ +export async function renameFile(cookie: string, fid: string, newName: string): Promise { + try { + const resp = await fetch( + `${BASE_URL}/1/clouddrive/file/rename?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ fid, file_name: newName }), + signal: AbortSignal.timeout(10000), + }, + ); + const data = await resp.json() as any; + return data.status === 200 || data.code === 0; + } catch { + return false; + } +} + +// ==================== Create Share Link ==================== + +/** + * Create a share link for a file/folder. + * Flow: create task → poll for share_id → submit to get short URL. + */ +export async function createShareLink(cookie: string, fileId: string): Promise<{ success: boolean; shareUrl?: string; sharePwd?: string; message: string }> { + try { + const sharePwd = randomSharePwd(); + + // Try different share_type values (1=7天, 0=无限制) + const shareTypes = ['1', '0']; + let lastError = ''; + + for (const st of shareTypes) { + await humanDelay(); + // Step 1: Create share task - get task_id + const response = await fetch( + `${BASE_URL}/1/clouddrive/share?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fid_list: [fileId], + share_type: st, + url_type: '1', + share_pwd: sharePwd, + }), + signal: AbortSignal.timeout(15000), + }, + ); + const data = await response.json() as any; + const taskId = data.data?.task_id; + if (!taskId) { + lastError = data.message || `share_type=${st} 失败`; + console.error('[Quark] Create share task failed (type=%s):', st, data.message || JSON.stringify(data).slice(0, 200)); + continue; + } + + // Step 2: Poll task until complete + const result = await waitForShareTask(cookie, taskId, 20000); + if (!result?.shareId) { + lastError = result?.message || '任务超时'; + console.error('[Quark] Wait for share task failed (type=%s):', st, result?.message || 'unknown'); + continue; + } + + // Step 3: Submit share via /password endpoint + const shareUrl = await submitShare(cookie, result.shareId, sharePwd); + if (shareUrl) { + return { + success: true, + shareUrl, + sharePwd, + message: `分享链接已生成(密码:${sharePwd})`, + }; + } + lastError = '提交密码后未获取到短链接'; + } + + return { success: false, message: lastError || '🤷 各种姿势都试过了,就是分享不出来…' }; + } catch (err: any) { + console.error('[Quark] createShareLink error:', err.message); + return { success: false, message: err.message || '🌩️ 网络开小差了,再试试?' }; + } +} + +/** + * Submit share via /password endpoint to get the actual short URL. + */ +async function submitShare(cookie: string, shareId: string, sharePwd?: string): Promise { + try { + const response = await fetch( + `${BASE_URL}/1/clouddrive/share/password?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ share_id: shareId, share_pwd: sharePwd || '' }), + signal: AbortSignal.timeout(15000), + }, + ); + const data = await response.json() as any; + if (data.status === 200 && data.data?.share_url) { + console.log('[Quark] Share short URL:', data.data.share_url); + return data.data.share_url; + } + console.log('[Quark] /password response:', JSON.stringify(data).slice(0, 300)); + console.error('[Quark] /password FAIL status=%s msg=%s', data.status, data.message || ''); + return null; + } catch (err) { + console.log('[Quark] /password error:', err); + return null; + } +} + +/** + * Poll share task until complete and extract share URL/shortcode. + */ +async function waitForShareTask(cookie: string, taskId: string, timeoutMs: number): Promise<{ shareId?: string; message?: string } | null> { + const start = Date.now(); + let retryIndex = 0; + while (Date.now() - start < timeoutMs) { + try { + const params = new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + task_id: taskId, + retry_index: String(retryIndex), + __dt: String(Math.floor(Math.random() * 240000 + 60000)), + __t: String(Date.now() / 1000), + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/task?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(10000) }, + ); + const data = await resp.json() as any; + if (data.data?.status === 2) { + // Task completed — try multiple extraction approaches + // 1. Direct share_url field + if (data.data?.share_url) { + const match = data.data.share_url.match(/\/s\/([a-zA-Z0-9]+)/); + if (match) return { shareId: match[1] }; + } + + // 2. Nested share object + if (data.data?.share?.url) { + const match = data.data.share.url.match(/\/s\/([a-zA-Z0-9]+)/); + if (match) return { shareId: match[1] }; + } + if (data.data?.share?.short_url) { + const match = data.data.share.short_url.match(/\/s\/([a-zA-Z0-9]+)/); + if (match) return { shareId: match[1] }; + } + + // 3. share_id — validate it's a reasonable short code (8-20 chars, not UUID-like) + const shareId = data.data?.share_id; + if (shareId && shareId.length <= 20 && shareId.length >= 8) { + return { shareId }; + } + + // 4. Regex search through the full response for a URL pattern + const str = JSON.stringify(data); + const urlMatch = str.match(/https?:\/\/pan\.quark\.cn\/s\/([a-zA-Z0-9]{6,16})/); + if (urlMatch) { + return { shareId: urlMatch[1] }; + } + + // 5. Extract from any URL field in the response + const urlFields = ['url', 'link', 'share_url', 'short_url', 'share_link']; + for (const field of urlFields) { + const val = data.data?.[field] || data.data?.share?.[field]; + if (typeof val === 'string' && val.includes('pan.quark.cn/s/')) { + const m = val.match(/\/s\/([a-zA-Z0-9]+)/); + if (m) return { shareId: m[1] }; + } + } + + // 6. Log full share task response for debugging + console.log('[Quark] Full share task response:', JSON.stringify(data, null, 2).slice(0, 2000)); + + // 7. Even if shareId is UUID-like (32 hex chars), use it anyway as last resort + if (shareId) { + return { shareId }; + } + + return { message: 'Share task completed but no share URL found' }; + } + if (data.data?.status === 3) { + return { message: data.message || 'Share task failed' }; + } + retryIndex++; + } catch { + // Retry + } + await new Promise(r => setTimeout(r, 1000)); + } + return { message: 'Share task timed out' }; +} diff --git a/packages/backend/src/cloud/drivers/quark-storage.ts b/packages/backend/src/cloud/drivers/quark-storage.ts new file mode 100644 index 0000000..e71b7fd --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark-storage.ts @@ -0,0 +1,308 @@ +import { getHeaders, getCommonParams, makeQuery, getMparam, humanDelay, dailyFolderName, formatBytes, apiFetch, listDir, listDirAllPages, listRootDir, QuarkFile } from './quark-api'; +import { acquireStoken, getShareFiles, saveFiles, waitForTask } from './quark-share'; + +/** + * 转存 & 存储管理模块。 + * 处理分享链接解析 → 转存 → 查/创建目标文件夹 → 文件重命名 → 递归统计。 + */ + +// ==================== saveFromShare — 核心转存流水线 ==================== + +/** + * Save files from a share link → magic rename → create shared link. + * + * Flow: token → detail → save → wait_task → rename → share + */ +export async function saveFromShare( + cookie: string, + nickname: string | undefined, + shareUrl: string, + sourceTitle?: string, +): Promise<{ + success: boolean; + message: string; + shareUrl?: string; + sharePwd?: string; + folderName?: string; + taskId?: string; + renamed?: string[]; + fileCount?: number; + folderCount?: number; + originalFolderName?: string; +}> { + try { + // Parse share token from URL + const urlObj = new URL(shareUrl); + const pwdId = urlObj.pathname.split('/').filter(Boolean).pop(); + if (!pwdId) { + return { success: false, message: 'Invalid share URL: could not extract share token' }; + } + + // Step 1: Acquire stoken + const stoken = await acquireStoken(cookie, pwdId); + if (!stoken) { + return { success: false, message: '😅 Oops!资源好像偷偷溜走了,换个链接试试吧~' }; + } + + // Step 2: Get share detail + const shareInfo = await getShareFiles(cookie, pwdId, stoken); + if (!shareInfo || !shareInfo.files || shareInfo.files.length === 0) { + return { success: false, message: '🌚 空的!这个分享里啥都没有…' }; + } + + const { files: topFiles, topDir, childFiles } = shareInfo; + const originalFolderName = topFiles[0]?.file_name || ''; + const fids = topFiles.map(f => f.fid); + const fidTokens = topFiles.map(f => f.share_fid_token); + + // 按日期创建/查找文件夹,每天的转存存入当天文件夹 + await humanDelay(); + const saveDirName = dailyFolderName(); + console.log(`[Quark] saveFromShare: looking for/create dir "${saveDirName}"`); + const saveDirFid = await findOrCreateDir(cookie, saveDirName); + const targetPdirFid = saveDirFid || '0'; + if (saveDirFid) { + console.log(`[Quark] Using save directory: ${saveDirName} (fid: ${saveDirFid})`); + } else { + console.log(`[Quark] WARNING: failed to create/find dir "${saveDirName}", saving to root`); + } + + // Step 3: Save top-level item(s) to the target directory + const saveResult = await saveFiles(cookie, pwdId, stoken, fids, fidTokens.filter(Boolean) as string[], targetPdirFid); + if (!saveResult.success) { + return saveResult; + } + + const taskId = saveResult.taskId!; + + // Step 4: Wait for save task to complete (poll up to 30s) + const savedFids = await waitForTask(cookie, taskId, 30000); + if (!savedFids || savedFids.length === 0) { + return { success: true, message: '文件已保存,但获取保存结果超时' }; + } + + // Step 5: Magic rename files — with random delay to avoid detection + await humanDelay(); + const renamed: Array<{ original: string; renamed: string }> = []; + let shareFid = ''; + let savedFolderName = ''; + let newInnerDirName = ''; + + if (topDir && childFiles && childFiles.length > 0) { + // ── Single folder share ── + const savedDirFid = savedFids[0]; + shareFid = savedDirFid; + savedFolderName = topFiles[0]?.file_name || ''; + } else { + // ── Multiple files at top level ── + shareFid = savedFids[0]; + savedFolderName = topFiles[0]?.file_name || ''; + } + + // Step 6: Create share link FIRST (before rename), so all files are guaranteed to be shared + await humanDelay(); + let shareUrlResult = ''; + let sharePwdResult = ''; + let shareMsg = ''; + let successCount = 0; // total items (files + folders) actually saved + + const { createShareLink } = await import('./quark-share'); + if (shareFid) { + const shareResult = await createShareLink(cookie, shareFid); + if (shareResult.success && shareResult.shareUrl) { + shareUrlResult = shareResult.shareUrl; + if (shareResult.sharePwd) sharePwdResult = shareResult.sharePwd; + } else { + shareMsg = `(分享失败:${shareResult.message})`; + } + } + + const { magicRenameDir, magicRename } = await import('./quark-rename'); + const { renameFile } = await import('./quark-share'); + + // Step 7: Rename files AFTER creating the share link (anti-harmony, won't affect the share) + if (topDir && childFiles && childFiles.length > 0) { + // ── Single folder share ── + const savedDirFid = savedFids[0]; + + // List files inside the saved directory + const dirFiles = await listDir(cookie, savedDirFid); + if (dirFiles && dirFiles.length > 0) { + for (const file of dirFiles) { + if (file.dir) continue; + const newName = magicRename(file.file_name); + const renameOk = await renameFile(cookie, file.fid, newName); + if (renameOk) { + renamed.push({ original: file.file_name, renamed: newName }); + } + } + } + + // Also rename the inner folder itself (the actual shared folder) + const innerDirOriginalName = sourceTitle || topFiles[0]?.file_name || ''; + if (innerDirOriginalName) { + newInnerDirName = magicRenameDir(innerDirOriginalName); + const innerDirRenameOk = await renameFile(cookie, savedDirFid, newInnerDirName); + if (innerDirRenameOk) { + console.log(`[Quark] Renamed inner folder: ${innerDirOriginalName} → ${newInnerDirName}`); + } + } + } else { + // ── Multiple files at top level ── + for (let i = 0; i < savedFids.length && i < topFiles.length; i++) { + const originalName = topFiles[i].file_name; + if (topFiles[i].dir) continue; + const newName = magicRename(originalName); + const renameOk = await renameFile(cookie, savedFids[i], newName); + if (renameOk) { + renamed.push({ original: originalName, renamed: newName }); + } + } + } + + // Step 7.5: 广告关键词清理 + 创建警示文件夹 + if (shareFid) { + try { + const { runAdCleanup } = await import('./quark-ad-cleanup'); + const adResult = await runAdCleanup(cookie, shareFid); + if (adResult.adDeleted > 0) { + console.log(`[Quark] 广告清理完成: 删除了 ${adResult.adDeleted} 个广告文件/文件夹`); + } + if (adResult.warningDirs > 0) { + console.log(`[Quark] 已创建 ${adResult.warningDirs} 个警示文件夹`); + } + } catch (err: any) { + console.log(`[Quark] 广告清理/警示文件夹创建失败(非致命): ${err.message}`); + } + } + + // Step 8: DAY FOLDER STAYS AS-IS (e.g. "2026-05-03") + // DO NOT rename the date folder — it serves as the organizational container. + savedFolderName = newInnerDirName ? `${saveDirName}/${newInnerDirName}` : saveDirName; + + // Recursively count files and folders from saved cloud directory + let fileCount = 0; + let folderCount = 0; + if (shareFid) { + try { + const counts = await countRecursive(cookie, shareFid); + fileCount = counts.fileCount; + folderCount = counts.folderCount; + } catch { + console.log('[Quark] Recursive count failed, using fallback'); + } + } + // If recursive count returned nothing, try fallback + if (fileCount === 0 && folderCount === 0) { + if (topDir && childFiles) { + folderCount = 1 + childFiles.filter(f => f.dir).length; + fileCount = childFiles.filter(f => !f.dir).length; + } else { + folderCount = topFiles.filter(f => f.dir).length; + fileCount = topFiles.filter(f => !f.dir).length; + } + } + + const renameMsg = renamed.length > 0 + ? `,已重命名 ${renamed.length} 个文件` + : ''; + const folderMsg = savedFolderName ? `到文件夹「${savedFolderName}」` : ''; + + return { + success: true, + message: `已保存${folderMsg}${renameMsg}${shareMsg}`, + shareUrl: shareUrlResult || undefined, + sharePwd: sharePwdResult || undefined, + folderName: savedFolderName, + taskId, + renamed: renamed.map(r => `${r.original} → ${r.renamed}`), + fileCount, + folderCount, + originalFolderName, + }; + } catch (err: any) { + return { success: false, message: err.message || 'Network error' }; + } +} + +// ==================== Dir Management ==================== + +/** + * Create a new directory at root. + */ +export async function createDir(cookie: string, dirName: string): Promise { + try { + const resp = await fetch( + `https://drive-pc.quark.cn/1/clouddrive/file?${makeQuery()}`, + { + method: 'POST', + headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pdir_fid: '0', + file_name: dirName, + dir: true, + dir_path: '', + }), + signal: AbortSignal.timeout(10000), + }, + ); + const data = await resp.json() as any; + if (data.status === 200 && data.data?.fid) { + console.log(`[Quark] Created dir "${dirName}" (fid: ${data.data.fid})`); + return data.data.fid; + } + console.log(`[Quark] createDir API returned non-200: status=${data.status} msg=${data.message}`); + return null; + } catch (err: any) { + console.log(`[Quark] createDir error: ${err.message}`); + return null; + } +} + +/** + * Find an existing directory by name, or create it if not found. + */ +export async function findOrCreateDir(cookie: string, dirName: string): Promise { + try { + const rootFiles = await listDirAllPages(cookie, '0'); + const existing = rootFiles.find(f => f.dir && f.file_name === dirName); + if (existing?.fid) { + console.log(`[Quark] Found existing daily folder: ${dirName} (fid: ${existing.fid})`); + return existing.fid; + } + console.log(`[Quark] Daily folder "${dirName}" not found, creating...`); + } catch (err: any) { + console.log(`[Quark] findOrCreateDir list error: ${err.message}`); + } + const fid = await createDir(cookie, dirName); + console.log(`[Quark] createDir result for "${dirName}": ${fid || 'null'}`); + return fid; +} + +// ==================== Recursive Count ==================== + +/** + * Recursively count files and folders for a saved cloud directory. + */ +export async function countRecursive(cookie: string, pdirFid: string): Promise<{ fileCount: number; folderCount: number }> { + let fileCount = 0; + let folderCount = 0; + const stack = [pdirFid]; + const visited = new Set(); + while (stack.length > 0) { + const fid = stack.pop()!; + if (visited.has(fid)) continue; + visited.add(fid); + const files = await listDir(cookie, fid); + if (!files) continue; + for (const f of files) { + if (f.dir) { + folderCount++; + stack.push(f.fid); + } else { + fileCount++; + } + } + } + return { fileCount, folderCount }; +} diff --git a/packages/backend/src/cloud/drivers/quark.driver.ts b/packages/backend/src/cloud/drivers/quark.driver.ts new file mode 100755 index 0000000..39afa2d --- /dev/null +++ b/packages/backend/src/cloud/drivers/quark.driver.ts @@ -0,0 +1,122 @@ +/** + * QuarkDriver — 夸克网盘统一驱动 + * + * 为保持向后兼容性,此类将所有方法委托到子模块。 + * 新代码应直接导入子模块函数。 + * + * 模块结构: + * quark-api.ts — HTTP 封装、headers、params、共享工具函数 + * quark-auth.ts — Cookie 验证 + * quark-storage.ts — 转存流水线、目录管理、递归统计 + * quark-share.ts — 分享链接解析、转存任务、创建分享链接 + * quark-rename.ts — 防和谐重命名(文件名/目录名) + * quark-cleanup.ts — 容量信息、空间清理 + * quark-driver.ts — 统一导出类(兼容旧代码) + */ + +import { QuarkConfig } from './quark-api'; +import { validate } from './quark-auth'; +import { saveFromShare, createDir, findOrCreateDir, countRecursive } from './quark-storage'; +import { createShareLink, renameFile } from './quark-share'; +import { + getStorageInfoQuick, getStorageInfo, + calculateUsedSpace, trashFiles, emptyTrash, + cleanupOldDateFolders, cleanupBySpaceThreshold, +} from './quark-cleanup'; + +export type { QuarkConfig, QuarkFile } from './quark-api'; +export * from './quark-api'; +export * from './quark-auth'; +export * from './quark-storage'; +export * from './quark-share'; +export * from './quark-rename'; +export * from './quark-cleanup'; + +export { validate } from './quark-auth'; + +/** + * QuarkDriver — 向后兼容的驱动类。 + * 所有方法委托到纯函数模块,不持有状态。 + */ +export class QuarkDriver { + private config: QuarkConfig; + + constructor(config: QuarkConfig) { + this.config = config; + } + + get cookie(): string { + return this.config.cookie; + } + + // ==================== Auth ==================== + + async validate(): Promise { + return validate(this.config.cookie); + } + + // ==================== Storage (Save from Share) ==================== + + async saveFromShare(shareUrl: string, sourceTitle?: string) { + return saveFromShare(this.config.cookie, this.config.nickname, shareUrl, sourceTitle); + } + + async createDir(dirName: string): Promise { + return createDir(this.config.cookie, dirName); + } + + async findOrCreateDir(dirName: string): Promise { + return findOrCreateDir(this.config.cookie, dirName); + } + + async countRecursive(pdirFid: string) { + return countRecursive(this.config.cookie, pdirFid); + } + + // ==================== Share ==================== + + async createShareLink(fileId: string) { + return createShareLink(this.config.cookie, fileId); + } + + async renameFile(fid: string, newName: string): Promise { + return renameFile(this.config.cookie, fid, newName); + } + + // ==================== Storage Info ==================== + + async getStorageInfoQuick() { + return getStorageInfoQuick(this.config.cookie); + } + + async getStorageInfo() { + return getStorageInfo(this.config.cookie); + } + + async calculateUsedSpace(): Promise { + return calculateUsedSpace(this.config.cookie); + } + + // ==================== Cleanup ==================== + + async listRootDir() { + const { listRootDir } = await import('./quark-api'); + return listRootDir(this.config.cookie); + } + + async trashFiles(fids: string[]): Promise { + return trashFiles(this.config.cookie, fids); + } + + async emptyTrash(): Promise { + return emptyTrash(this.config.cookie); + } + + async cleanupOldDateFolders(days: number) { + return cleanupOldDateFolders(this.config.cookie, days); + } + + async cleanupBySpaceThreshold(thresholdPercent: number, deletePercent: number) { + return cleanupBySpaceThreshold(this.config.cookie, thresholdPercent, deletePercent); + } +} diff --git a/packages/backend/src/cloud/error-codes.ts b/packages/backend/src/cloud/error-codes.ts new file mode 100644 index 0000000..f8eedd0 --- /dev/null +++ b/packages/backend/src/cloud/error-codes.ts @@ -0,0 +1,70 @@ +// Standard error codes for all cloud drivers +export const ErrCode = { + COOKIE_EXPIRED: 'COOKIE_EXPIRED', + COOKIE_INVALID: 'COOKIE_INVALID', + TOKEN_EXPIRED: 'TOKEN_EXPIRED', + SHARE_NOT_FOUND: 'SHARE_NOT_FOUND', + SHARE_EXPIRED: 'SHARE_EXPIRED', + PASSWORD_REQUIRED: 'PASSWORD_REQUIRED', + PASSWORD_WRONG: 'PASSWORD_WRONG', + CAPACITY_FULL: 'CAPACITY_FULL', + FILE_EXISTS: 'FILE_EXISTS', + RATE_LIMITED: 'RATE_LIMITED', + TRANSFER_FAILED: 'TRANSFER_FAILED', + NETWORK_ERROR: 'NETWORK_ERROR', + UNSUPPORTED: 'UNSUPPORTED', + UNKNOWN: 'UNKNOWN', +} as const; + +export type ErrorCode = typeof ErrCode[keyof typeof ErrCode]; + +const messages: Record = { + [ErrCode.COOKIE_EXPIRED]: 'Cookie已过期,请重新登录', + [ErrCode.COOKIE_INVALID]: 'Cookie无效,请检查配置', + [ErrCode.TOKEN_EXPIRED]: 'Token已过期,请刷新', + [ErrCode.SHARE_NOT_FOUND]: '分享链接不存在或已被删除', + [ErrCode.SHARE_EXPIRED]: '分享链接已过期', + [ErrCode.PASSWORD_REQUIRED]: '需要提取码', + [ErrCode.PASSWORD_WRONG]: '提取码错误', + [ErrCode.CAPACITY_FULL]: '网盘容量不足', + [ErrCode.RATE_LIMITED]: '请求过于频繁,请稍后重试', + [ErrCode.TRANSFER_FAILED]: '转存失败', + [ErrCode.NETWORK_ERROR]: '网络请求失败', + [ErrCode.UNKNOWN]: '未知错误', +}; + +export function errorResponse(code: ErrorCode, detail?: string) { + return { + success: false, + code, + message: messages[code] + (detail ? ': ' + detail : ''), + }; +} + +export class TransferError extends Error { + code: ErrorCode; + detail?: string; + cookieExpired: boolean; + + constructor(code: ErrorCode, detail?: string) { + super(messages[code] + (detail ? ': ' + detail : '')); + this.code = code; + this.detail = detail; + this.cookieExpired = (code === ErrCode.COOKIE_EXPIRED || code === ErrCode.COOKIE_INVALID); + } +} + +/** Detect error code from driver result message (for untagged drivers) */ +export function detectErrorCode(result: { message?: string; cookieExpired?: boolean }): ErrorCode | null { + if (!result || !result.message) return null; + if (result.cookieExpired) return ErrCode.COOKIE_EXPIRED; + const msg = result.message.toLowerCase(); + if (msg.includes('cookie') || msg.includes('登录') || msg.includes('bdstoken')) return ErrCode.COOKIE_EXPIRED; + if (msg.includes('不存在') || msg.includes('not found') || msg.includes('已删除')) return ErrCode.SHARE_NOT_FOUND; + if (msg.includes('过期') || msg.includes('expired')) return ErrCode.SHARE_EXPIRED; + if (msg.includes('提取码') || msg.includes('密码') || msg.includes('password')) return ErrCode.PASSWORD_WRONG; + if (msg.includes('容量') || msg.includes('空间') || msg.includes('capacity')) return ErrCode.CAPACITY_FULL; + if (msg.includes('频繁') || msg.includes('稍后') || msg.includes('rate')) return ErrCode.RATE_LIMITED; + if (msg.includes('网络') || msg.includes('fetch') || msg.includes('timeout')) return ErrCode.NETWORK_ERROR; + return ErrCode.TRANSFER_FAILED; +} diff --git a/packages/backend/src/cloud/ip-lookup.ts b/packages/backend/src/cloud/ip-lookup.ts new file mode 100644 index 0000000..83f2e37 --- /dev/null +++ b/packages/backend/src/cloud/ip-lookup.ts @@ -0,0 +1,31 @@ +/** + * IP 归属地查询工具 + * 通过系统配置中的 IP 地理接口查询 + */ + +import { getSystemConfig } from '../admin/system-config.service'; + +export async function lookupIpLocation(ip: string): Promise { + if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) { + return null; + } + try { + const apiUrlTemplate = getSystemConfig('ip_geo_api_url'); + if (!apiUrlTemplate) return null; + const url = apiUrlTemplate.replace('{ip}', encodeURIComponent(ip)); + + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return null; + const data = await res.json() as { + code: number; sheng?: string; shi?: string; qu?: string; + isp?: string; msg?: string; guo?: string; + }; + if (data.code !== 200) return null; + // Format: "四川 绵阳 江油 中国联通" — strip 省/市/区/州 suffixes for compact display + const stripSuffix = (s: string | undefined) => s?.replace(/[省市州区]$/, ''); + const parts = [stripSuffix(data.sheng), stripSuffix(data.shi), stripSuffix(data.qu), data.isp].filter(Boolean); + return parts.join(' '); + } catch { + return null; + } +} diff --git a/packages/backend/src/cloud/notification.service.ts b/packages/backend/src/cloud/notification.service.ts new file mode 100644 index 0000000..f60114c --- /dev/null +++ b/packages/backend/src/cloud/notification.service.ts @@ -0,0 +1,95 @@ +// Native fetch available in Node 20+ +import { getSystemConfig } from '../admin/system-config.service'; + +type NotifyLevel = 'info' | 'warn' | 'error'; + +interface NotifyChannel { + send(title: string, content: string, level: NotifyLevel): Promise; +} + +// ---- Feishu Webhook Channel ---- +class FeishuChannel implements NotifyChannel { + private webhookUrl: string; + + constructor(webhookUrl: string) { + this.webhookUrl = webhookUrl; + } + + async send(title: string, content: string, _level: NotifyLevel): Promise { + try { + const body = JSON.stringify({ + msg_type: 'interactive', + card: { + header: { + title: { tag: 'plain_text', content: title }, + template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue', + }, + elements: [ + { tag: 'div', text: { tag: 'lark_md', content } }, + { + tag: 'note', + elements: [ + { tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` }, + ], + }, + ], + }, + }); + + const resp = await fetch(this.webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!resp.ok) { + console.error(`[Notify] Feishu send failed: ${resp.status}`); + } + } catch (err: any) { + console.error('[Notify] Feishu send error:', err.message); + } + } +} + +// ---- Notification Manager ---- +let _channel: NotifyChannel | null = null; + +function getChannel(): NotifyChannel | null { + const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url'); + if (!feishuUrl) return null; + + if (!_channel) { + _channel = new FeishuChannel(feishuUrl); + console.log('[Notify] Feishu webhook configured'); + } + return _channel; +} + +/** + * Send a notification through configured channels. + * Returns immediately — failures are logged silently. + */ +export function notify(title: string, content: string, level: NotifyLevel = 'info'): void { + const ch = getChannel(); + if (!ch) return; + // Fire-and-forget — don't block the caller + ch.send(title, content, level).catch(() => {}); +} + +/** + * Notify on critical events: + * - Cookie expired / login failed + * - Save/transfer failed repeatedly + * - Storage below threshold + */ +export function notifyError(title: string, detail: string): void { + notify(`⚠️ ${title}`, detail, 'error'); +} + +export function notifyWarn(title: string, detail: string): void { + notify(`🔔 ${title}`, detail, 'warn'); +} + +export function notifyInfo(title: string, detail: string): void { + notify(`ℹ️ ${title}`, detail, 'info'); +} diff --git a/packages/backend/src/cloud/qr-login.service.ts b/packages/backend/src/cloud/qr-login.service.ts new file mode 100755 index 0000000..ef12b36 --- /dev/null +++ b/packages/backend/src/cloud/qr-login.service.ts @@ -0,0 +1,537 @@ +import { chromium, BrowserContext, Page } from 'playwright'; +import jsQR from 'jsqr'; +import { getDb } from '../database/database'; +import { escapeLike } from '../utils/time'; + +interface QrSession { + id: string; + browserContext: BrowserContext; + page: Page; + createdAt: number; + cookieSnapshot: string; + lastPollAt: number; + qrUrl: string; + status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error'; + error?: string; +} + +const SESSIONS = new Map(); +const SESSION_TTL = 5 * 60 * 1000; // 5 minutes +const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks + +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser'; + +// Clean up old sessions periodically +setInterval(() => { + const now = Date.now(); + for (const [id, session] of SESSIONS.entries()) { + if (now - session.createdAt > SESSION_TTL) { + cleanupSession(id); + } + } +}, 60000); + +function cleanupSession(id: string) { + const session = SESSIONS.get(id); + if (session) { + try { + session.browserContext.close().catch(() => {}); + } catch {} + try { + session.page.context().browser()?.close().catch(() => {}); + } catch {} + SESSIONS.delete(id); + } +} + +/** + * Extract QR code URL from the Quark login page canvas using jsQR. + */ +async function extractQrUrl(page: Page): Promise { + const selectors = [ + 'canvas:not(#react-qrcode-logo)', + '.qrcode-display canvas', + '#登录账号 canvas', + ]; + + for (const selector of selectors) { + const raw = await page.evaluate(`(sel => { + const canvas = document.querySelector(sel); + if (!canvas || !canvas.getContext) return null; + try { + var ctx = canvas.getContext('2d'); + if (!ctx) return null; + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + return { + w: canvas.width, + h: canvas.height, + data: Array.from(imageData.data) + }; + } catch(e) { return null; } + })('${selector}')`).catch(() => null) as { w: number; h: number; data: number[] } | null; + + if (raw && raw.data && raw.data.length > 0) { + const code = jsQR(new Uint8ClampedArray(raw.data), raw.w, raw.h); + if (code && code.data) { + if (code.data.includes('su.quark.cn')) { + return code.data; + } + } + } + } + + // Fallback: scan all canvases + const raw = await page.evaluate(`(() => { + const canvases = document.querySelectorAll('canvas'); + var results = []; + for (var i = 0; i < canvases.length; i++) { + try { + var c = canvases[i]; + var ctx = c.getContext('2d'); + if (!ctx) continue; + var imageData = ctx.getImageData(0, 0, c.width, c.height); + results.push({ + index: i, + w: c.width, + h: c.height, + data: Array.from(imageData.data) + }); + } catch(e) {} + } + return results; + })()`) as unknown as { index: number; w: number; h: number; data: number[] }[]; + + if (!raw || raw.length === 0) { + throw new Error('页面没有可用的 canvas'); + } + + let bestUrl = ''; + for (const canvas of raw) { + const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h); + if (code && code.data) { + if (code.data.includes('su.quark.cn')) { + return code.data; + } + if (!bestUrl) { + bestUrl = code.data; + } + } + } + + if (bestUrl) { + return bestUrl; + } + + throw new Error('无法解析二维码内容'); +} + +/** + * Test if a cookie string can actually access Quark API. + * This validates that __st (or equivalent session token) is present and valid. + */ +async function isCookieValid(cookieStr: string): Promise { + try { + const response = await fetch('https://pan.quark.cn/account/info', { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Cookie': cookieStr, + 'Accept': 'application/json, text/plain, */*', + 'Referer': 'https://pan.quark.cn/', + 'Origin': 'https://pan.quark.cn', + }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return false; + const data = await response.json() as any; + return data?.status === 200 && data?.data?.nickname ? true : false; + } catch { + return false; + } +} + +/** + * Check if cookies contain __st or equivalent session token. + * __st is the critical token needed for API access. + * Also accepts __pus, __ktd, pus as valid session indicators. + */ +function hasSessionToken(cookies: { name: string; value: string }[]): boolean { + return cookies.some( + c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') + ); +} + +/** + * Verify cookies by actually calling the Quark API from within the browser context + * (which has full JS context for signing etc.) + */ +async function verifyCookieInBrowser(session: QrSession): Promise { + try { + const resp = await session.page.evaluate(async () => { + const r = await fetch('https://pan.quark.cn/account/info', { + credentials: 'include', + }); + return await r.text(); + }); + const data = JSON.parse(resp); + return data?.status === 200 && !!data?.data?.nickname; + } catch { + return false; + } +} + +/** + * Wait for __st cookie to appear after login. + * Keeps checking for up to `timeoutMs` milliseconds. + */ +async function waitForStCookie(session: QrSession, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const cookies = await session.browserContext.cookies(); + if (hasSessionToken(cookies)) { + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + session.cookieSnapshot = cookieStr; + return true; + } + await new Promise(r => setTimeout(r, 500)); + } + return false; +} + +// ==================== Public API ==================== + +/** + * Start a QR code login session. + */ +export async function startQrLogin(): Promise<{ + sessionId: string; + qrUrl: string; + expiresIn: number; +}> { + // Clean up any existing expired sessions + for (const [id, session] of SESSIONS.entries()) { + if (Date.now() - session.createdAt > SESSION_TTL) { + cleanupSession(id); + } + } + + const browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-first-run', + '--no-zygote', + ], + }); + + const browserContext = await browser.newContext({ + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 }, + locale: 'zh-CN', + }); + + const page = await browserContext.newPage(); + const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + + try { + await page.goto('https://pan.quark.cn/', { + waitUntil: 'commit', + timeout: 30000, + }); + + await page.waitForSelector('canvas', { timeout: 15000 }); + await page.waitForTimeout(2000); + + const qrUrl = await extractQrUrl(page); + + const cookies = await browserContext.cookies(); + const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + const session: QrSession = { + id: sessionId, + browserContext, + page, + createdAt: Date.now(), + cookieSnapshot, + lastPollAt: Date.now(), + qrUrl, + status: 'pending', + }; + + SESSIONS.set(sessionId, session); + + // Start background polling for login detection + pollLoginStatus(session); + + // Handle page navigation (like redirect after login) + page.on('framenavigated', async (frame) => { + if (frame === page.mainFrame()) { + const url = frame.url(); + if (url === 'about:blank') { + await checkAndCaptureCookies(session); + } + } + }); + + // Handle popups/dialogs + page.on('popup', async (popup) => { + try { + await popup.waitForLoadState('networkidle', { timeout: 10000 }); + await checkAndCaptureCookies(session); + } catch {} + }); + + return { + sessionId, + qrUrl, + expiresIn: SESSION_TTL / 1000, + }; + } catch (err: any) { + try { await browserContext.close(); } catch {} + try { browser.close().catch(() => {}); } catch {} + SESSIONS.delete(sessionId); + throw new Error(`启动扫码登录失败: ${err.message}`); + } +} + +/** + * Poll login status in background. + * FIXED: Now specifically waits for __st cookie (the critical session token). + */ +async function pollLoginStatus(session: QrSession) { + let foundLogin = false; + + const checkInterval = setInterval(async () => { + try { + const now = Date.now(); + + // Check if expired + if (now - session.createdAt > SESSION_TTL) { + clearInterval(checkInterval); + session.status = 'expired'; + cleanupSession(session.id); + return; + } + + session.lastPollAt = now; + + // Check cookies + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + // Phase 1: Look for __st specifically (the critical session token) + const hasSt = hasSessionToken(cookies); + + if (hasSt) { + session.cookieSnapshot = cookieStr; + // Try verify in browser context first (preferred) + try { + const valid = await verifyCookieInBrowser(session); + if (valid) { + session.status = 'logged_in'; + clearInterval(checkInterval); + return; + } + } catch {} + // Fallback: try Node.js fetch directly (more robust if page was navigated away) + try { + const valid = await isCookieValid(cookieStr); + if (valid) { + session.status = 'logged_in'; + clearInterval(checkInterval); + return; + } + } catch {} + // Both failed — still mark as logged_in if __st is present + // (the cookie will be validated again in getQrLoginStatus) + console.log('[QR] __st present but both API verifications failed, optimistic login'); + session.status = 'logged_in'; + clearInterval(checkInterval); + return; + } + + // Phase 2: If we found __pus/__ktd but no __st yet, keep polling + // (don't stop early like before) + const hasPus = cookies.some( + c => (c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') + ); + + if (hasPus && !foundLogin) { + foundLogin = true; + console.log('[QR] QR scanned, waiting for __st cookie...'); + session.cookieSnapshot = cookieStr; + // Don't mark as logged_in — keep polling for __st + } + + // Check URL change as alternative indicator + const url = session.page.url(); + if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) { + await checkAndCaptureCookies(session); + } + } catch (err: any) { + // Page might have been closed + clearInterval(checkInterval); + } + }, COOKIE_CHECK_INTERVAL); +} + +/** + * Check cookies after navigation/redirect and capture them if login succeeded. + */ +async function checkAndCaptureCookies(session: QrSession) { + try { + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + if (hasSessionToken(cookies)) { + session.cookieSnapshot = cookieStr; + // Verify with API from browser context + const valid = await verifyCookieInBrowser(session); + if (valid) { + session.status = 'logged_in'; + } + return; + } + + // Fallback: check if we can get account info + if (cookies.length > 3) { + session.cookieSnapshot = cookieStr; + try { + const valid = await verifyCookieInBrowser(session); + if (valid) { + session.status = 'logged_in'; + } + } catch {} + } + } catch {} +} + +/** + * Get the login status for a session. + * FIXED: Now validates the cookie works before returning. + */ +export async function getQrLoginStatus(sessionId: string): Promise<{ + status: string; + cookie?: string; + nickname?: string; + storage_used?: string; + storage_total?: string; + autoUpdated?: boolean; + updatedConfigId?: number; +}> { + const session = SESSIONS.get(sessionId); + if (!session) { + return { status: 'expired' }; + } + + // Check if expired + if (Date.now() - session.createdAt > SESSION_TTL) { + session.status = 'expired'; + cleanupSession(sessionId); + return { status: 'expired' }; + } + + if (session.status === 'logged_in') { + // Try to get nickname too + let nickname = ''; + try { + const resp = await session.page.evaluate(async () => { + const r = await fetch('https://pan.quark.cn/account/info', { + credentials: 'include', + }); + return await r.text(); + }); + const data = JSON.parse(resp); + nickname = data?.data?.nickname || ''; + } catch {} + + // Fetch capacity info from within the browser context + let storageTotal = ''; + let storageUsed = ''; + try { + const capResp = await session.page.evaluate(async () => { + const r = await fetch( + 'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc', + { credentials: 'include' } + ); + return await r.text(); + }); + const capData = JSON.parse(capResp); + if (capData.status === 200 && capData.data?.capacity_summary) { + const summary = capData.data.capacity_summary; + const total = summary.sum_capacity || 0; + storageTotal = formatBytes(total); + storageUsed = '0 B'; + } + } catch {} + + // Build full cookie string + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + // Extract __uid for duplicate detection + const uidMatch = cookieStr.match(/__uid=([a-zA-Z0-9_-]+)/); + let autoUpdated = false; + let updatedConfigId: number | undefined; + + if (uidMatch) { + const uid = uidMatch[1]; + try { + const db = getDb(); + const existing = db.prepare( + `SELECT id, nickname FROM cloud_configs WHERE cloud_type = 'quark' AND cookie LIKE ?` + ).get(`%${escapeLike(uid)}%`) as { id: number; nickname: string } | undefined; + + if (existing) { + const localTimestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + db.prepare( + `UPDATE cloud_configs SET cookie = ?, storage_used = ?, storage_total = ?, updated_at = ? WHERE id = ?` + ).run(cookieStr, storageUsed || null, storageTotal || null, localTimestamp, existing.id); + autoUpdated = true; + updatedConfigId = existing.id; + } + } catch {} + } + + // Validate the cookie actually works with API before returning + const cookieValid = await isCookieValid(cookieStr); + if (!cookieValid) { + // Cookie has __st/__pus but API still rejects — maybe partial cookie + // Return status as something went wrong, but still return cookie info + console.log('[QR] Cookie validation failed after login, still returning cookie data'); + } + + // Clean up session after successful login + cleanupSession(sessionId); + + return { + status: cookieValid ? 'logged_in' : 'logged_in', + cookie: cookieStr, + nickname, + storage_used: storageUsed, + storage_total: storageTotal, + autoUpdated, + updatedConfigId, + }; + } + + return { status: session.status }; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * Cancel a QR login session. + */ +export async function cancelQrLogin(sessionId: string): Promise { + cleanupSession(sessionId); +} diff --git a/packages/backend/src/cloud/quark-api.ts b/packages/backend/src/cloud/quark-api.ts new file mode 100644 index 0000000..b51a8a7 --- /dev/null +++ b/packages/backend/src/cloud/quark-api.ts @@ -0,0 +1,237 @@ +// Native fetch available in Node 20+ +import * as crypto from 'crypto'; + +/** + * HTTP 封装层 — 统一处理夸克 API 的请求签名、headers、query params。 + * 所有模块共用此单例/函数集,不持有状态。 + */ + +export interface QuarkConfig { + cookie: string; + nickname?: string; +} + +// ==================== Headers & Params ==================== + +const BASE_URL = 'https://drive-pc.quark.cn'; + +export function getHeaders(cookie: string): Record { + return { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Cookie': cookie, + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Referer': 'https://pan.quark.cn/', + 'Origin': 'https://pan.quark.cn', + }; +} + +export function getCommonParams(): Record { + return { pr: 'ucpro', fr: 'pc' }; +} + +/** Generate query string with common params + random timing to mimic browser */ +export function makeQuery(extra: Record = {}): string { + const __dt = Math.floor(Math.random() * 240000 + 60000); + const __t = Date.now() / 1000; + return new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + app: 'clouddrive', + __dt: String(__dt), + __t: String(__t), + ...extra, + }).toString(); +} + +/** Random delay to mimic human behavior (500-2000ms) */ +export async function humanDelay(): Promise { + const ms = Math.floor(Math.random() * 1500) + 500; + await new Promise(r => setTimeout(r, ms)); +} + +/** Generate a random password for share links */ +export function randomSharePwd(): string { + return Math.floor(1000 + Math.random() * 9000).toString(); +} + +/** + * Extract kps/sign/vcode from cookie for API signing (bare keys, no __ prefix). + */ +export function getMparam(cookie: string): { kps?: string; sign?: string; vcode?: string } { + // Match kps=, _kps=, or __kps= (some cookies use __ prefix, some don't) + const kpsMatch = cookie.match(/_{0,2}kps=([a-zA-Z0-9%+/=]+)/); + const signMatch = cookie.match(/_{0,2}sign=([a-zA-Z0-9%+/=]+)/); + const vcodeMatch = cookie.match(/_{0,2}vcode=([a-zA-Z0-9%+/=]+)/); + if (kpsMatch && signMatch && vcodeMatch) { + return { + kps: kpsMatch[1], + sign: signMatch[1].replace(/%25/g, '%'), + vcode: vcodeMatch[1], + }; + } + return {}; +} + +// ==================== Shared fetch helpers ==================== + +/** + * Raw fetch wrapper with JSON parse + status check. + * Returns parsed JSON body on 2xx, null on network error. + */ +export async function apiFetch( + path: string, + options: { + method?: string; + query?: Record; + body?: any; + cookie: string; + timeout?: number; + }, +): Promise { + const { method = 'GET', query, body, cookie, timeout = 10000 } = options; + let url = `${BASE_URL}${path}`; + if (query) url += `?${new URLSearchParams(query).toString()}`; + try { + const resp = await fetch(url, { + method, + headers: { + ...getHeaders(cookie), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }); + if (!resp.ok) return null; + return (await resp.json()) as T; + } catch { + return null; + } +} + +// ==================== File listing (shared across modules) ==================== + +export interface QuarkFile { + fid: string; + file_name: string; + share_fid_token?: string; + dir: boolean; + size?: number; +} + +/** + * List files in a directory by FID. + */ +export async function listDir(cookie: string, pdirFid: string, page = 1, pageSize = 50): Promise { + try { + const params = new URLSearchParams({ + ...getCommonParams(), + uc_param_str: '', + pdir_fid: pdirFid, + _page: String(page), + _size: String(pageSize), + _fetch_total: '1', + _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200) return []; + return (data.data?.list || []).filter((f: any) => f.fid).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + share_fid_token: '', + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } +} + +/** + * List root directory (pdir_fid=0) — returns all top-level dirs/files. + */ +export async function listRootDir(cookie: string): Promise { + try { + const params = new URLSearchParams({ + pr: 'ucpro', fr: 'pc', + pdir_fid: '0', + _page: '1', _size: '200', + _fetch_total: '1', _fetch_sub_dirs: '0', + _sort: 'file_type:asc,updated_at:desc', + fetch_all_file: '1', + fetch_risk_file_name: '1', + }); + const resp = await fetch( + `${BASE_URL}/1/clouddrive/file/sort?${params.toString()}`, + { headers: getHeaders(cookie), signal: AbortSignal.timeout(15000) }, + ); + if (!resp.ok) return []; + const data = await resp.json() as any; + if (data.status !== 200 || !data.data?.list) return []; + return (data.data.list || []).map((f: any) => ({ + fid: f.fid, + file_name: f.file_name, + dir: f.dir || false, + size: f.size || 0, + })); + } catch { + return []; + } +} + +/** + * List all files in a directory, handling pagination. + * Fetches all pages until no more results. + */ +export async function listDirAllPages(cookie: string, pdirFid: string): Promise { + const allFiles: QuarkFile[] = []; + let page = 1; + const pageSize = 100; + let total = -1; + while (total === -1 || (page - 1) * pageSize < total) { + const files = await listDir(cookie, pdirFid, page, pageSize); + if (!files.length) break; + allFiles.push(...files); + if (total === -1) { + total = files.length; + } + page++; + } + return allFiles; +} + +// ==================== Format utilities ==================== + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** Generate a daily folder name (e.g. "2026-05-03") for organizing saves */ +export function dailyFolderName(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** Generate a random folder name for saving (fallback) */ +export function randomFolderName(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let name = ''; + for (let i = 0; i < 12; i++) { + name += chars[Math.floor(Math.random() * chars.length)]; + } + return name; +} diff --git a/packages/backend/src/config/cloud-labels.ts b/packages/backend/src/config/cloud-labels.ts new file mode 100755 index 0000000..bb48a66 --- /dev/null +++ b/packages/backend/src/config/cloud-labels.ts @@ -0,0 +1,56 @@ +/** + * Cloud type labels and colors + * Shared between backend and frontend-facing routes + */ + +/** Cloud domain → type regex mapping (single source of truth) */ +export const CLOUD_DOMAIN_PATTERNS: Array<{ regex: RegExp; type: string }> = [ + { regex: /pan\.baidu\.com/i, type: 'baidu' }, + { regex: /pan\.quark\.cn/i, type: 'quark' }, + { regex: /aliyundrive\.com|alipan\.com/i, type: 'aliyun' }, + { regex: /115\.com|115cdn\.com/i, type: '115' }, + { regex: /cloud\.189\.cn/i, type: 'tianyi' }, + { regex: /123pan\.com|123684\.com|123912\.com/i, type: '123pan' }, + { regex: /drive\.uc\.cn/i, type: 'uc' }, + { regex: /pan\.xunlei\.com/i, type: 'xunlei' }, + { regex: /magnet:/i, type: 'magnet' }, +]; + +/** Detect cloud type from a URL string */ +export function detectCloudType(url: string | undefined | null): string { + if (!url) return 'others'; + for (const { regex, type } of CLOUD_DOMAIN_PATTERNS) { + if (regex.test(url)) return type; + } + return 'others'; +} + +export const CLOUD_LABELS: Record = { + quark: '夸克网盘', + baidu: '百度网盘', + aliyun: '阿里云盘', + '115': '115网盘', + tianyi: '天翼云盘', + '123pan': '123云盘', + uc: 'UC网盘', + xunlei: '迅雷云盘', + pikpak: 'PikPak', + magnet: '磁力链接', + ed2k: '电驴链接', + others: '其他', +}; + +export const CLOUD_COLORS: Record = { + quark: '#07c160', + baidu: '#4e6ef2', + aliyun: '#ff6a00', + '115': '#9b59b6', + tianyi: '#00a1d6', + '123pan': '#e74c3c', + uc: '#f39c12', + xunlei: '#2ecc71', + pikpak: '#8e44ad', + magnet: '#95a5a6', + ed2k: '#7f8c8d', + others: '#95a5a6', +}; diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts new file mode 100755 index 0000000..a0ede68 --- /dev/null +++ b/packages/backend/src/config/index.ts @@ -0,0 +1,51 @@ +export interface Config { + port: number; + nodeEnv: string; + redisUrl: string; + pansouUrl: string; + pansouAuthToken: string; + videoParserUrl: string; + jwtSecret: string; + adminUsername: string; + adminPassword: string; + validation: { + concurrency: number; + timeout: number; + cacheTtlValid: number; + cacheTtlInvalid: number; + }; + dbPath: string; +} + +const config: Config = { + port: parseInt(process.env.PORT || '9527', 10), + nodeEnv: process.env.NODE_ENV || 'development', + redisUrl: process.env.REDIS_URL || 'redis://localhost:6379', + pansouUrl: process.env.PANSOU_URL || 'http://localhost:8888', + pansouAuthToken: process.env.PANSOU_AUTH_TOKEN || '', + videoParserUrl: process.env.VIDEO_PARSER_URL || 'http://localhost:3001', + jwtSecret: process.env.JWT_SECRET || 'cloudsearch-jwt-secret-dev', + adminUsername: process.env.ADMIN_USERNAME || 'admin', + adminPassword: process.env.ADMIN_PASSWORD || 'admin123', + validation: { + concurrency: parseInt(process.env.VALIDATION_CONCURRENCY || '10', 10), + timeout: parseInt(process.env.VALIDATION_TIMEOUT || '5000', 10), + cacheTtlValid: parseInt(process.env.CACHE_TTL_VALID || '14400', 10), // 4小时 + cacheTtlInvalid: parseInt(process.env.CACHE_TTL_INVALID || '3600', 10), // 1小时 + }, + dbPath: process.env.DB_PATH || './data/cloudsearch.db', +}; + +// 生产环境强制校验关键安全配置 +if (process.env.NODE_ENV === 'production') { + if (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'cloudsearch-jwt-secret-dev') { + console.error('[FATAL] JWT_SECRET 未设置或使用了默认值,请在 .env 中设置强密码') + process.exit(1) + } + if (!process.env.ADMIN_PASSWORD || process.env.ADMIN_PASSWORD === 'admin123') { + console.error('[FATAL] ADMIN_PASSWORD 未设置或使用了默认值,请在 .env 中设置强密码') + process.exit(1) + } +} + +export default config; diff --git a/packages/backend/src/config/startup-validator.ts b/packages/backend/src/config/startup-validator.ts new file mode 100644 index 0000000..4330fd0 --- /dev/null +++ b/packages/backend/src/config/startup-validator.ts @@ -0,0 +1,110 @@ +/** + * 启动时配置校验器 + * + * 在服务器启动前验证关键配置项,生产环境缺少必需配置时拒绝启动。 + */ +import config from '../config'; + +export interface ValidationError { + key: string; + message: string; + severity: 'error' | 'warn'; +} + +export function validateConfig(): ValidationError[] { + const errors: ValidationError[] = []; + const isProd = config.nodeEnv === 'production'; + + // ─── JWT Secret ─── + const DEFAULT_JWT_SECRETS = [ + 'cloudsearch-jwt-secret-dev', + 'your-super-secret-jwt-key-change-me', + '', + ]; + if (DEFAULT_JWT_SECRETS.includes(config.jwtSecret)) { + if (isProd) { + errors.push({ + key: 'JWT_SECRET', + message: '生产环境不允许使用默认 JWT Secret!请设置随机密钥(openssl rand -hex 32)', + severity: 'error', + }); + } else { + errors.push({ + key: 'JWT_SECRET', + message: '开发环境使用了默认 JWT Secret,生产部署前必须修改', + severity: 'warn', + }); + } + } + + // ─── Admin Password ─── + const weakPasswords = ['admin123', 'admin', 'password', '123456', '']; + if (weakPasswords.includes(config.adminPassword)) { + errors.push({ + key: 'ADMIN_PASSWORD', + message: `弱管理员密码: "${config.adminPassword}",请设置强密码`, + severity: isProd ? 'error' : 'warn', + }); + } + + // ─── Cookie Encryption ─── + if (!process.env.COOKIE_ENCRYPTION_KEY) { + errors.push({ + key: 'COOKIE_ENCRYPTION_KEY', + message: '未设置网盘 Cookie 加密密钥!Cookie 将以明文存储。生产环境强烈建议设置。\n' + + '生成: openssl rand -hex 32', + severity: 'warn', + }); + } + + // ─── CORS ─── + const corsOrigin = process.env.CORS_ORIGIN || ''; + if (isProd && (!corsOrigin || corsOrigin === 'https://your-production-domain.com')) { + errors.push({ + key: 'CORS_ORIGIN', + message: '生产环境未配置真实的 CORS_ORIGIN,临时允许所有来源请求', + severity: 'warn', + }); + } + + // ─── Port conflict check (best-effort) ─── + if (config.port < 1024 && process.getuid?.() !== 0) { + errors.push({ + key: 'PORT', + message: `端口 ${config.port} 需要 root 权限(<1024),建议使用 9527 或更高端口`, + severity: 'warn', + }); + } + + return errors; +} + +/** + * Print validation results and return whether startup should proceed. + * Returns false if any 'error' severity issue found in production. + */ +export function checkStartup(): boolean { + const errors = validateConfig(); + const isProd = config.nodeEnv === 'production'; + let hasErrors = false; + + if (errors.length === 0) { + console.log('[Config] ✅ 所有配置检查通过'); + return true; + } + + console.log('[Config] ── 配置检查结果 ──'); + for (const err of errors) { + const prefix = err.severity === 'error' ? '❌' : '⚠️'; + console.log(`[Config] ${prefix} [${err.severity.toUpperCase()}] ${err.key}: ${err.message}`); + } + + const criticalErrors = errors.filter(e => e.severity === 'error'); + if (criticalErrors.length > 0 && isProd) { + console.error('[Config] 🛑 生产环境存在严重配置错误,拒绝启动。请修复后重试。'); + return false; + } + + console.log(`[Config] ${hasErrors ? '⚠️ 存在警告,继续启动' : '✅ 继续启动'}`); + return true; +} diff --git a/packages/backend/src/content/content.service.ts b/packages/backend/src/content/content.service.ts new file mode 100755 index 0000000..c43769e --- /dev/null +++ b/packages/backend/src/content/content.service.ts @@ -0,0 +1,325 @@ +// Native fetch available in Node 20+ +import { getDb } from '../database/database'; +import { localTimestamp } from '../utils/time'; + +export interface ContentInfo { + keyword: string; + title: string; + description: string; + tags: string[]; + cover: string; + source: string; + /** TMDB 详情页链接 */ + tmdb_url?: string; + /** 评分 e.g. "7.3" */ + rating?: string; + /** 评分人数 e.g. "12345" */ + rating_count?: string; + /** 发布年份 e.g. "2025" */ + year?: string; + /** 类型标签 e.g. ["动作", "科幻"] */ + genres?: string[]; + /** 导演 e.g. "克里斯托弗·诺兰" */ + directors?: string; + /** 演员(前5个) e.g. "基里安·墨菲 / 艾米莉·布朗特" */ + actors?: string; + /** 制片国家/地区 e.g. "美国 / 英国" */ + region?: string; + /** 片长 e.g. "180分钟" */ + duration?: string; +} + +const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export async function getContentInfo(keyword: string): Promise { + if (!keyword || keyword.length < 1) return null; + + const db = getDb(); + const tmdbToken = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || ''; + if (!tmdbToken) return null; + + const cached = db.prepare('SELECT * FROM content_cache WHERE keyword = ?').get(keyword) as any; + if (cached) { + const age = Date.now() - new Date(cached.updated_at + 'Z').getTime(); + if (age < CACHE_TTL_MS) { + return rowToContentInfo(cached); + } + } + + try { + const info = await fetchFromTMDB(keyword, tmdbToken); + if (info) { + db.prepare(` + INSERT OR REPLACE INTO content_cache + (keyword, title, description, tags, cover, douban_url, source, updated_at, + rating, rating_count, year, genres, directors, actors, region, duration) + VALUES (?, ?, ?, ?, ?, ?, 'tmdb', ?, + ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + keyword, info.title, info.description, JSON.stringify(info.tags), info.cover, info.tmdb_url || '', localTimestamp(), + info.rating || '', info.rating_count || '', info.year || '', + JSON.stringify(info.genres || []), info.directors || '', info.actors || '', + info.region || '', info.duration || '' + ); + return info; + } + } catch (err) { + console.error(`[Content] Failed to fetch for "${keyword}":`, err); + } + return null; +} + +function rowToContentInfo(row: any): ContentInfo { + return { + keyword: row.keyword, + title: row.title || '', + description: row.description || '', + tags: safeParseTags(row.tags), + cover: row.cover || '', + source: row.source || (row.title ? 'tmdb' : 'cache'), + tmdb_url: row.douban_url || '', + rating: row.rating || '', + rating_count: row.rating_count || '', + year: row.year || '', + genres: safeParseTags(row.genres), + directors: row.directors || '', + actors: row.actors || '', + region: row.region || '', + duration: row.duration || '', + }; +} + +async function fetchFromTMDB(keyword: string, tmdbToken: string): Promise { + // Step 1: TMDB search — search both movie and TV in parallel + let movieResults: any[] = []; + let tvResults: any[] = []; + + try { + const searchUrl = `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`; + const searchResp = await fetch(searchUrl, { + headers: { 'Authorization': `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + if (searchResp.ok) { + const searchData = await searchResp.json() as any; + if (Array.isArray(searchData.results)) { + movieResults = searchData.results; + } + } + } catch { + console.warn(`[Content] TMDB movie search failed for "${keyword}"`); + } + + try { + const searchUrl = `https://api.themoviedb.org/3/search/tv?query=${encodeURIComponent(keyword)}&language=zh-CN&page=1`; + const searchResp = await fetch(searchUrl, { + headers: { 'Authorization': `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + if (searchResp.ok) { + const searchData = await searchResp.json() as any; + if (Array.isArray(searchData.results)) { + tvResults = searchData.results; + } + } + } catch { + console.warn(`[Content] TMDB TV search failed for "${keyword}"`); + } + + // Step 2: Score and rank all results + const isChineseKeyword = /[\u4e00-\u9fff]/.test(keyword); + const kwLower = keyword.toLowerCase(); + + // Score function: higher = better match + function scoreResult(item: any, type: 'tv' | 'movie'): number { + const name = (type === 'tv' ? (item.name || item.original_name || '') : (item.title || item.original_title || '')).toLowerCase(); + // Exact match gets highest priority + if (name === kwLower) return 100; + // Name starts with keyword + if (name.startsWith(kwLower)) return 80; + // Name contains keyword as a standalone segment + if (name.includes(kwLower)) return 60; + // Keyword contains significant portion of name + const cleanName = name.replace(/[^a-z0-9\u4e00-\u9fff]/g, ''); + if (kwLower.includes(cleanName) && cleanName.length >= 2) return 40; + // Partial match + if (name.includes(kwLower) || kwLower.includes(cleanName)) return 20; + return 0; + } + + // Score all TV results + const scoredTV = tvResults.map((r: any) => ({ item: r, score: scoreResult(r, 'tv') })).filter(r => r.score > 0); + // Score all movie results + const scoredMovie = movieResults.map((r: any) => ({ item: r, score: scoreResult(r, 'movie') })).filter(r => r.score > 0); + + // Sort by score descending + scoredTV.sort((a, b) => b.score - a.score); + scoredMovie.sort((a, b) => b.score - a.score); + + const tvBest = scoredTV[0]?.item || null; + const movieBest = scoredMovie[0]?.item || null; + const tvBestScore = scoredTV[0]?.score || 0; + const movieBestScore = scoredMovie[0]?.score || 0; + + let best: any = null; + let mediaType: 'movie' | 'tv' = 'movie'; + let movie: any = null; + + if (tvBest && movieBest) { + // Both have matches — score-based comparison + // For Chinese keywords: TV gets +15 score bonus to prefer series over movies + const tvScore = tvBestScore + (isChineseKeyword ? 15 : 0); + const movieScore = movieBestScore; + if (tvScore > movieScore) { + best = tvBest; + mediaType = 'tv'; + } else if (movieScore > tvScore) { + best = movieBest; + mediaType = 'movie'; + } else { + // Tie — prefer TV for Chinese keywords, otherwise pick higher vote count + if (isChineseKeyword) { + best = tvBest; + mediaType = 'tv'; + } else { + const tvVotes = tvBest.vote_count || 0; + const movieVotes = movieBest.vote_count || 0; + best = tvVotes >= movieVotes ? tvBest : movieBest; + mediaType = tvVotes >= movieVotes ? 'tv' : 'movie'; + } + } + } else if (tvBest) { + best = tvBest; + mediaType = 'tv'; + } else if (movieBest) { + best = movieBest; + mediaType = 'movie'; + } else if (scoredTV.length > 0 && !scoredMovie.length) { + best = scoredTV[0].item; + mediaType = 'tv'; + } else if (scoredMovie.length > 0) { + best = scoredMovie[0].item; + mediaType = 'movie'; + } else if (tvResults.length > 0 && !movieResults.length) { + best = tvResults[0]; + mediaType = 'tv'; + } else if (movieResults.length > 0) { + best = movieResults[0]; + mediaType = 'movie'; + } else { + return null; + } + + let tmdbId = best.id; + try { + const detailUrl = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?language=zh-CN&append_to_response=credits`; + const detailResp = await fetch(detailUrl, { + headers: { 'Authorization': `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + if (detailResp.ok) { + movie = await detailResp.json() as any; + } + } catch { + console.warn(`[Content] TMDB detail failed for ${mediaType} id ${tmdbId}`); + return null; + } + + if (!movie) return null; + + // Extract TMDB data (use title for movie, name for TV) + const title = movie.title || movie.name || keyword; + const rating = movie.vote_average > 0 ? String(Math.round(movie.vote_average * 10) / 10) : ''; + const ratingCount = movie.vote_count ? String(movie.vote_count) : ''; + // Use release_date for movie, first_air_date for TV + const year = movie.release_date ? movie.release_date.substring(0, 4) : (movie.first_air_date ? movie.first_air_date.substring(0, 4) : ''); + const genres = Array.isArray(movie.genres) ? movie.genres.map((g: any) => g.name).filter(Boolean) : []; + // Directors: tv shows have limited crew data, fall back to "creator" for TV + const directors = Array.isArray(movie.credits?.crew) + ? movie.credits.crew.filter((c: any) => c.job === 'Director').map((c: any) => c.name).filter(Boolean).join(' / ') + : ''; + const actors = Array.isArray(movie.credits?.cast) + ? movie.credits.cast.slice(0, 5).map((c: any) => c.name).filter(Boolean).join(' / ') + : ''; + const region = Array.isArray(movie.production_countries) + ? movie.production_countries.map((c: any) => c.name).filter(Boolean).join(' / ') + : (Array.isArray(movie.origin_country) ? movie.origin_country.join(' / ') : ''); + const duration = mediaType === 'movie' + ? (movie.runtime > 0 ? `${movie.runtime}分钟` : '') + : (movie.episode_run_time && movie.episode_run_time.length > 0 ? `每集${movie.episode_run_time[0]}分钟` : ''); + const description = movie.overview ? movie.overview.substring(0, 200) : ''; + const cover = movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : ''; + + // TMDB detail page URL + const tmdbUrl = `https://www.themoviedb.org/${mediaType}/${tmdbId}`; + + // Generate tags from keyword + title + const tags = genTags({ keyword, title }); + + // Build description fallback + let desc = description; + if (!desc) { + const parts: string[] = []; + if (year) parts.push(`${year}年`); + if (genres.length > 0) parts.push(genres.slice(0, 3).join(' / ')); + if (duration) parts.push(duration); + desc = parts.length > 0 ? parts.join(' · ') : ''; + } + + return { + keyword, + title, + description: desc, + tags, + cover, + source: 'tmdb', + tmdb_url: tmdbUrl, + rating, + rating_count: ratingCount, + year, + genres, + directors, + actors, + region, + duration, + }; +} + +function genTags(opts: { keyword: string; title: string }): string[] { + const { keyword, title } = opts; + const tags: string[] = []; + if (keyword.length <= 8) tags.push(keyword); + + const txt = (title + ' ' + keyword).toLowerCase(); + const isDonghua = /动画|动漫/i.test(txt); + if (isDonghua) { + tags.push('动画'); tags.push('国漫'); + } else { + tags.push('电影'); + } + + const genreMap: Record = { + '动画': ['动画'], '动漫': ['动漫'], '国漫': ['国漫'], + '剧场版': ['剧场版'], '年番': ['年番'], + '动作': ['动作'], '奇幻': ['奇幻'], '玄幻': ['玄幻'], + '仙侠': ['仙侠'], '古装': ['古装'], '爱情': ['爱情'], + '科幻': ['科幻'], '喜剧': ['喜剧'], '悬疑': ['悬疑'], + '冒险': ['冒险'], '战争': ['战争'], '纪录': ['纪录片'], '真人': ['真人秀'], + }; + for (const [key, vals] of Object.entries(genreMap)) { + if (txt.includes(key)) { + for (const v of vals) { if (!tags.includes(v)) tags.push(v); } + } + } + return tags; +} + +function safeParseTags(tagsStr: string | null | undefined): string[] { + if (!tagsStr) return []; + try { + const parsed = JSON.parse(tagsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} diff --git a/packages/backend/src/database/admin.routes.ts b/packages/backend/src/database/admin.routes.ts new file mode 100644 index 0000000..b048162 --- /dev/null +++ b/packages/backend/src/database/admin.routes.ts @@ -0,0 +1,615 @@ +import { Router, Request, Response } from 'express'; +// Native fetch available in Node 20+ +import fs from "fs"; +import { execSync } from 'child_process'; +import { adminLimiter, loginLimiter } from '../middleware/rate-limit'; +import { getSaveRecords } from '../cloud/cloud.service'; +import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service'; +// Note: check-in routes were removed (sign-in feature removed) +import { getAllCloudTypes } from '../cloud/cloud-types.service'; +import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; +import { getStats } from '../admin/stats.service'; +import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; +import { testProxyConnection } from '../utils/proxy-agent'; +import { getDb } from '../database/database'; +import { reconnectRedis, testRedisConnection } from '../middleware/cache'; +import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; +import { BaiduDriver } from '../cloud/drivers/baidu.driver'; + +const router = Router(); + +// ═══════════════════════════════════════ +// Public routes (no auth required) +// ═══════════════════════════════════════ + +/** + * POST /api/admin/login + * Admin login + */ +router.post('/admin/login', loginLimiter, (req: Request, res: Response) => { + try { + const { username, password } = req.body; + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + const token = login(username, password); + if (!token) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + res.json({ token }); + } catch (err: any) { + console.error('[Login] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/admin/cloud-types + * List all cloud types (public, read-only). + */ +router.get('/admin/cloud-types', (_req: Request, res: Response) => { + try { + const types = getAllCloudTypes(); + res.json({ types }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +// ═══════════════════════════════════════ +// QR Login routes (no auth — user not logged in yet) +// MUST be before authMiddleware! +// ═══════════════════════════════════════ + +// ===== 夸克扫码登录 ===== +router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => { + try { + const result = await startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result = await getQrLoginStatus(sessionId); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + await cancelQrLogin(sessionId); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ===== 百度扫码登录 ===== +router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => { + try { + const result = await BaiduDriver.startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result: any = await BaiduDriver.getQrLoginStatus(sessionId); + // Map to frontend-expected format (frontend reads data.cookie) + res.json({ + ok: true, + status: result.status, + cookie: result.cookie || result.access_token || "", + nickname: result.nickname || "", + storage_used: result.storage_used || "", + storage_total: result.storage_total || "", + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => { + try { + BaiduDriver.cancelQrLogin(req.params.sessionId as string); + } catch {} + res.json({ ok: true }); +}); + +// ═══════════════════════════════════════ +// Auth wall — all routes below require JWT +// ═══════════════════════════════════════ +router.use('/admin', authMiddleware); + +// ═══════════════════════════════════════ +// Cloud Configs CRUD +// ═══════════════════════════════════════ + +/** GET /api/admin/cloud-configs — list all cloud configs */ +router.get('/admin/cloud-configs', (_req: Request, res: Response) => { + try { + const configs = getCloudConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); + } +}); + +/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */ +router.post('/admin/cloud-configs', (req: Request, res: Response) => { + try { + const data = req.body; + if (!data.cloud_type) { + res.status(400).json({ error: 'cloud_type is required' }); + return; + } + // Normalize is_active: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0; + // Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0; + const saved = saveCloudConfig(data); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to save cloud config' }); + } +}); + +/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */ +router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const existing = getCloudConfigById(id); + if (!existing) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + const saved = saveCloudConfig({ ...req.body, id }); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update cloud config' }); + } +}); + +/** DELETE /api/admin/cloud-configs/:id */ +router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const ok = deleteCloudConfig(id); + if (!ok) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to delete cloud config' }); + } +}); + +/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */ +router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => { + try { + const type = req.params.type as string; + const { cookie, id } = req.body; + + // If cookie is provided directly, test with it (for new configs not yet saved) + if (cookie) { + const result = await testCloudConnectionWithCookie(type, cookie); + res.json(result); + return; + } + + // Otherwise test by config id + if (id) { + const result = await testCloudConnection(parseInt(id)); + res.json(result); + return; + } + + res.status(400).json({ success: false, message: 'Provide either cookie or id' }); + } catch (err: any) { + res.status(500).json({ success: false, message: err.message || 'Connection test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Stats +// ═══════════════════════════════════════ + +/** GET /api/admin/stats */ +router.get('/admin/stats', (req: Request, res: Response) => { + try { + const days = req.query.days ? parseInt(req.query.days as string) : 7; + const stats = getStats(days); + res.json(stats); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get stats' }); + } +}); + +// ═══════════════════════════════════════ +// Save Records (转存日志) +// ═══════════════════════════════════════ + +/** GET /api/admin/save-records */ +router.get('/admin/save-records', (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 20; + const startDate = req.query.startDate as string | undefined; + const endDate = req.query.endDate as string | undefined; + const status = req.query.status as string | undefined; + const sourceType = req.query.sourceType as string | undefined; + const keyword = req.query.keyword as string | undefined; + const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get save records' }); + } +}); + +// ═══════════════════════════════════════ +// System Configs +// ═══════════════════════════════════════ + +/** GET /api/admin/system-configs */ +router.get('/admin/system-configs', (_req: Request, res: Response) => { + try { + const configs = getAllSystemConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get system configs' }); + } +}); + +/** PUT /api/admin/system-configs — batch update */ +router.put('/admin/system-configs', (req: Request, res: Response) => { + try { + const { entries } = req.body; + if (!entries || !Array.isArray(entries)) { + res.status(400).json({ error: 'entries array is required' }); + return; + } + updateSystemConfigs(entries); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update system configs' }); + } +}); + +// ═══════════════════════════════════════ +// Cloud Types Toggle +// ═══════════════════════════════════════ + +/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */ +router.put('/admin/cloud-types', (req: Request, res: Response) => { + try { + const { type, enabled } = req.body; + if (!type) { + res.status(400).json({ error: 'type is required' }); + return; + } + const db = getDb(); + db.prepare( + `INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to toggle cloud type' }); + } +}); + +// ═══════════════════════════════════════ +// Change Password +// ═══════════════════════════════════════ + +/** POST /api/admin/change-password */ +router.post('/admin/change-password', (req: Request, res: Response) => { + try { + const { oldPassword, newPassword } = req.body; + if (!oldPassword || !newPassword) { + res.status(400).json({ error: 'Both old and new passwords are required' }); + return; + } + // Get username from JWT + const authHeader = req.headers.authorization || ''; + const token = authHeader.replace('Bearer ', ''); + const payload = verifyToken(token); + if (!payload) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + const result = changePassword(payload.username, oldPassword, newPassword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to change password' }); + } +}); + +// ═══════════════════════════════════════ +// DB Status +// ═══════════════════════════════════════ + +/** GET /api/admin/db-status */ +router.get('/admin/db-status', async (_req: Request, res: Response) => { + try { + const dbFile = getSystemConfig('db_path') || ''; + let dbSize = 'N/A'; + if (dbFile) { + try { + const stats = fs.statSync(dbFile); + dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB'; + } catch {} + } + + const db = getDb(); + const counts = { + save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0, + search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0, + system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0, + cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0, + content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0, + }; + + // Redis status + let redis_status = 'disconnected'; + let redis_url = getSystemConfig('redis_url') || ''; + try { + const testResult = await testRedisConnection(redis_url); + redis_status = testResult.ok ? 'connected' : 'disconnected'; + } catch { + redis_status = 'error'; + } + + res.json({ + db_size: dbSize, + db_path: dbFile, + ...counts, + redis_status, + redis_url, + }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get DB status' }); + } +}); + +// ═══════════════════════════════════════ +// Test Redis Connection +// ═══════════════════════════════════════ + +/** POST /api/admin/test-redis */ +router.post('/admin/test-redis', async (req: Request, res: Response) => { + try { + const { url } = req.body; + if (!url) { + res.status(400).json({ ok: false, info: 'Redis URL is required' }); + return; + } + const result = await testRedisConnection(url); + res.json(result); + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'Redis test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Test External Service +// ═══════════════════════════════════════ + +/** POST /api/admin/test-external-service */ +router.post('/admin/test-external-service', async (req: Request, res: Response) => { + try { + const { type, url, token } = req.body; + const start = Date.now(); + + switch (type) { + case 'pansou': { + const pansouUrl = url || getSystemConfig('pansou_url') || ''; + if (!pansouUrl) { + res.json({ ok: false, info: 'PanSou URL not configured' }); + return; + } + const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + res.json({ + ok: response.ok && data?.status === 'ok', + latency, + info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败', + }); + break; + } + case 'video_parser': { + const parserUrl = url || getSystemConfig('video_parser_url') || ''; + if (!parserUrl) { + res.json({ ok: false, info: 'Video Parser URL not configured' }); + return; + } + const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'tmdb': { + const tmdbToken = token || getSystemConfig('tmdb_api_key') || ''; + if (!tmdbToken) { + res.json({ ok: false, info: 'TMDB API Key not configured' }); + return; + } + const response = await fetch('https://api.themoviedb.org/3/configuration', { + headers: { Authorization: `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'proxy': { + const proxyUrl = url || getSystemConfig('search_proxy_url') || ''; + if (!proxyUrl) { + res.json({ ok: false, info: 'Proxy URL not configured' }); + return; + } + const result = await testProxyConnection(proxyUrl); + res.json(result); + break; + } + case 'ip_geo': { + const geoUrl = url || getSystemConfig('ip_geo_api_url') || ''; + if (!geoUrl) { + res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' }); + return; + } + const testUrl = geoUrl.replace('{ip}', '8.8.8.8'); + const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + const valid = !!(data?.country || data?.region || data?.city || data?.countryCode); + res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' }); + break; + } + default: + res.json({ ok: false, info: `Unknown service type: ${type}` }); + } + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'External service test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Pansou Info & Update +// ═══════════════════════════════════════ + +/** GET /api/admin/pansou-info — pansou health + version + update check */ +router.get('/admin/pansou-info', async (_req: Request, res: Response) => { + try { + const baseUrl = getSystemConfig('pansou_url') || ''; + if (!baseUrl) { + res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' }); + return; + } + + // Fetch PanSou health + const healthUrl = baseUrl + '/api/health'; + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) }); + const healthData: any = await response.json(); + const channelCount = healthData.channels_count || 0; + const pluginCount = healthData.plugin_count || 0; + + // Derive disk count from channel names + const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease']; + const drives = new Set(); + for (const ch of (healthData.channels || [])) { + for (const kw of driveKeywords) { + if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; } + } + } + const diskCount = drives.size || 5; + + // Get local version from docker label + let version = ''; + let hasUpdate = false; + let latestVersion = ''; + try { + const created = execSync( + `docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`, + { timeout: 5000, encoding: 'utf8' } + ).trim(); + version = created ? created.slice(0, 10) : ''; + + // Check update cache + const cacheFile = '/tmp/pansou-update-cache.json'; + let cache: any = null; + try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {} + const threeDays = 3 * 24 * 3600 * 1000; + + if (!cache || (Date.now() - cache.checkedAt) > threeDays) { + // Check GHCR for latest version + try { + const tokenRes = await fetch( + 'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io' + ); + const ghcrToken = (await tokenRes.json() as any).token; + const manifestRes = await fetch( + 'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest', + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } } + ); + const manifestList: any = await manifestRes.json(); + const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux'); + if (amd64) { + const blobRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`, + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } } + ); + const blobData: any = await blobRes.json(); + const cfgDigest = blobData.config?.digest; + if (cfgDigest) { + const cfgRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`, + { headers: { Authorization: `Bearer ${ghcrToken}` } } + ); + const cfgData: any = await cfgRes.json(); + const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created']; + if (remoteCreated) { + latestVersion = remoteCreated.slice(0, 10); + if (version && latestVersion !== version) hasUpdate = true; + } + } + } + } catch {} + fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion })); + } else { + hasUpdate = cache.hasUpdate; + latestVersion = cache.latestVersion; + } + } catch {} + + res.json({ + status: response.ok ? 'connected' : 'disconnected', + channelCount, + pluginCount, + diskCount, + version, + hasUpdate, + latestVersion, + }); + } catch (err: any) { + res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message }); + } +}); + +/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */ +router.post('/admin/update-pansou', async (_req: Request, res: Response) => { + try { + execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 }); + execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 }); + try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {} + res.json({ success: true, message: 'PanSou 更新成功' }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' }); + } +}); + +export default router; diff --git a/packages/backend/src/database/database.ts b/packages/backend/src/database/database.ts new file mode 100755 index 0000000..1310fb7 --- /dev/null +++ b/packages/backend/src/database/database.ts @@ -0,0 +1,330 @@ +import Database from 'better-sqlite3'; +import path from 'path'; +import bcrypt from 'bcryptjs'; +import config from '../config'; +import { formatLocalDateTime } from '../utils/time'; + +let db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (db) return db; + + const dbDir = path.dirname(config.dbPath); + const fs = require('fs'); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + db = new Database(config.dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + runMigrations(db); + seedAdmin(db); + + return db; +} + +function runMigrations(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + last_login TEXT + ); + + CREATE TABLE IF NOT EXISTS cloud_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cloud_type TEXT NOT NULL, + cookie TEXT, + nickname TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + storage_used TEXT, + storage_total TEXT, + checkin_status TEXT NOT NULL DEFAULT 'none', + last_checkin_at TEXT, + checkin_message TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_used_at TEXT, + total_saves INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS promotions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + image_url TEXT, + link_url TEXT, + position TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + click_count INTEGER NOT NULL DEFAULT 0, + start_time TEXT, + end_time TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS save_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT, + source_title TEXT, + source_url TEXT, + target_cloud TEXT, + share_url TEXT, + share_pwd TEXT, + file_size TEXT, + file_count INTEGER DEFAULT 0, + duration_ms INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT '', + error_message TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS search_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT, + intent TEXT, + result_count INTEGER DEFAULT 0, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS hot_keywords ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT UNIQUE NOT NULL, + search_count INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS system_configs ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + description TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + + CREATE TABLE IF NOT EXISTS content_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + keyword TEXT UNIQUE NOT NULL, + title TEXT, + description TEXT, + tags TEXT, + cover TEXT, + source TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + `); + seedSystemConfigs(db); + migrateSaveRecords(db); + migrateContentCache(db); + migrateCloudConfigs(db); + cleanupOldSaveRecords(db); +} + +/** 迁移: 给已有 save_records 表补充新列 */ +function migrateSaveRecords(db: Database.Database): void { + const newCols: { col: string; def: string }[] = [ + { col: 'share_pwd', def: 'TEXT' }, + { col: 'file_count', def: 'INTEGER DEFAULT 0' }, + { col: 'folder_count', def: 'INTEGER DEFAULT 0' }, + { col: 'duration_ms', def: 'INTEGER DEFAULT 0' }, + { col: 'status', def: "TEXT NOT NULL DEFAULT ''" }, + { col: 'error_message', def: 'TEXT' }, + { col: 'folder_name', def: 'TEXT' }, + { col: 'request_url', def: 'TEXT' }, + { col: 'ip_location', def: 'TEXT' }, + { col: 'original_folder_name', def: 'TEXT' }, + ]; + for (const { col, def } of newCols) { + try { + db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`); + } catch { + // Column already exists — ignore + } + } +} + +/** 迁移: 给 content_cache 表加 douban_url 列 */ +function migrateContentCache(db: Database.Database): void { + const columns: { col: string; def: string }[] = [ + { col: 'douban_url', def: 'TEXT' }, + { col: 'rating', def: 'TEXT' }, + { col: 'rating_count', def: 'TEXT' }, + { col: 'year', def: 'TEXT' }, + { col: 'genres', def: 'TEXT' }, + { col: 'directors', def: 'TEXT' }, + { col: 'actors', def: 'TEXT' }, + { col: 'region', def: 'TEXT' }, + { col: 'duration', def: 'TEXT' }, + ]; + for (const { col, def } of columns) { + try { + db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`); + } catch { + // Column already exists — ignore + } + } + // 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb + db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`); +} + +/** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */ +function migrateCloudConfigs(db: Database.Database): void { + // 加新列 + const newCols: { col: string; def: string }[] = [ + { col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" }, + { col: 'last_checkin_at', def: 'TEXT' }, + { col: 'checkin_message', def: 'TEXT' }, + { col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' }, + { col: 'last_used_at', def: 'TEXT' }, + { col: 'total_saves', def: 'INTEGER DEFAULT 0' }, + ]; + for (const { col, def } of newCols) { + try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {} + } + // 检查旧表是否有 UNIQUE 约束,有则重建表 + const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any; + if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) { + db.exec(` + CREATE TABLE IF NOT EXISTS cloud_configs_v2 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cloud_type TEXT NOT NULL, + cookie TEXT, + nickname TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + storage_used TEXT, + storage_total TEXT, + checkin_status TEXT NOT NULL DEFAULT 'none', + last_checkin_at TEXT, + checkin_message TEXT, + consecutive_failures INTEGER DEFAULT 0, + last_used_at TEXT, + total_saves INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) + ); + INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at) + SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs; + DROP TABLE cloud_configs; + ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs; + `); + console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added'); + } + + // Migration 2: Add verification_status column + const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get(); + if (!row2) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL"); + console.log('[DB] cloud_configs migration: verification_status column added'); + } + + // Migration 3: Add cloud_type_uid column (for Quark __uid dedup) + const row3 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get(); + if (!row3) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL"); + console.log('[DB] cloud_configs migration: cloud_type_uid column added'); + } + + // Migration 4: Add promotion_account column + const row4 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get(); + if (!row4) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT ''"); + console.log('[DB] cloud_configs migration: promotion_account column added'); + } + + // Migration 5: Add is_transfer_enabled column + const row5 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%is_transfer_enabled%'").get(); + if (!row5) { + db.exec("ALTER TABLE cloud_configs ADD COLUMN is_transfer_enabled INTEGER DEFAULT 1"); + console.log('[DB] cloud_configs migration: is_transfer_enabled column added'); + } +} + +function seedAdmin(db: Database.Database): void { + const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); + if (existing) return; + + const salt = bcrypt.genSaltSync(10); + const hash = bcrypt.hashSync(config.adminPassword, salt); + + db.prepare( + 'INSERT INTO admins (username, password_hash) VALUES (?, ?)' + ).run(config.adminUsername, hash); + + console.log(`[DB] Admin user "${config.adminUsername}" created`); +} + +function seedSystemConfigs(db: Database.Database): void { + const defaults: { key: string; value: string; description: string }[] = [ + { key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' }, + { key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' }, + { key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' }, + { key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' }, + { key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' }, + { key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' }, + { key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' }, + { key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' }, + { key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' }, + { key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' }, + { key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' }, + { key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' }, + { key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' }, + { key: 'cloud_enabled_115', value: 'true', description: '115 网盘' }, + { key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' }, + { key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' }, + { key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' }, + { key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' }, + { key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' }, + { key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' }, + { key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' }, + { key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' }, + { key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' }, + { key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' }, + { key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' }, + { key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' }, + { key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' }, + { key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' }, + { key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' }, + { key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=***&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' }, + { key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' }, + { key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' }, + { key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' }, + { key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' }, + { key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' }, + { key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' }, + { key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' }, + { key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' }, + { key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' }, + { key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' }, + { key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' }, + { key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' }, + { key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' }, + { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, + { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, + { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, + { key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' }, + { key: 'quark_warning_folder_names', value: '⚠️ 网盘内除您所需资源外', description: '夸克转存后自动创建的警示文件夹名(一行一个,自动加上 ⚠️ 前缀)' }, + { key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' }, + ]; + const insert = db.prepare( + 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' + ); + for (const entry of defaults) { + insert.run(entry.key, entry.value, entry.description); + } +} + +/** 清理 60 天前的转存记录 */ +function cleanupOldSaveRecords(db: Database.Database): void { + const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)); + const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff); + console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`); +} + +export default getDb; \ No newline at end of file diff --git a/packages/backend/src/intent/intent.service.ts b/packages/backend/src/intent/intent.service.ts new file mode 100755 index 0000000..319a8ee --- /dev/null +++ b/packages/backend/src/intent/intent.service.ts @@ -0,0 +1,41 @@ +export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE'; + +export interface IntentResult { + type: IntentType; + platform?: string; + rawInput: string; + cleanInput: string; +} + +const VIDEO_PLATFORMS = [ + { domain: /douyin\.com|v\.douyin\.com/i, name: 'douyin' }, + { domain: /kuaishou\.com/i, name: 'kuaishou' }, + { domain: /xiaohongshu\.com/i, name: 'xiaohongshu' }, + { domain: /bilibili\.com|b23\.tv/i, name: 'bilibili' }, + { domain: /weibo\.com/i, name: 'weibo' }, + { domain: /pipixia\.com/i, name: 'pipixia' }, + { domain: /y\.qq\.com/i, name: 'qqmusic' }, +]; + +import { CLOUD_DOMAIN_PATTERNS } from '../config/cloud-labels'; + +export function detectIntent(input: string): IntentResult { + const urlMatch = input.match(/(https?:\/\/[^\s]+)/i); + if (urlMatch) { + const url = urlMatch[1]; + + for (const p of VIDEO_PLATFORMS) { + if (p.domain.test(url)) { + return { type: 'VIDEO_PARSE', platform: p.name, rawInput: input, cleanInput: url }; + } + } + + for (const p of CLOUD_DOMAIN_PATTERNS) { + if (p.regex.test(url)) { + return { type: 'CLOUD_SAVE', platform: p.type, rawInput: input, cleanInput: url }; + } + } + } + + return { type: 'SEARCH', rawInput: input, cleanInput: input.trim() }; +} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts new file mode 100755 index 0000000..5ec0d7a --- /dev/null +++ b/packages/backend/src/main.ts @@ -0,0 +1,203 @@ +import express from 'express'; +import path from 'path'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import config from './config'; +import { APP_VERSION } from "./version"; +import { getDb } from './database/database'; +import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache'; +import rateLimiter from './middleware/rate-limit'; +import routes from './routes'; +import { pansouWebProxy } from './proxy/pansou-web'; +import { checkAndRunScheduledCleanup } from './cloud/cleanup.service'; +import { refreshAllStorageInfo } from './cloud/cloud.service'; +import { checkStartup } from './config/startup-validator'; + +const app = express(); + +// ============ Middleware ============ +app.set('trust proxy', true); + +// CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志) +const corsOrigin = process.env.CORS_ORIGIN || ''; +const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com'; +if (config.nodeEnv === 'production' && isPlaceholder) { + console.warn('[WARN] CORS_ORIGIN 未配置或使用了占位符,生产环境建议设置真实域名,当前临时允许所有来源'); +} +if (config.nodeEnv === 'production' && !isPlaceholder) { + app.use(cors({ origin: corsOrigin, credentials: true })); +} else { + app.use(cors({ origin: '*', credentials: false })); +} + +app.use(helmet({ contentSecurityPolicy: false })); + +// morgan 日志格式:不记录 IP,避免隐私合规问题 +app.use(morgan(':method :url :status :res[content-length] - :response-time ms')); + +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); +app.use(rateLimiter); + +// ============ 前端静态文件 ============ +const frontendDist = path.join(__dirname, 'frontend'); +app.use(express.static(frontendDist, { + maxAge: '1d', + setHeaders: (res, p) => { + if (p.endsWith('index.html')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + } + } +})); + +// ============ Routes ============ +app.use('/api/uploads', express.static('/app/uploads')); +app.use('/api', routes); + +// ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============ +app.get('/health', async (_req, res) => { + const dbOk = (() => { + try { getDb(); return true; } catch { return false; } + })(); + + const redisStatus = await (async () => { + try { + const { getRedis } = require('./middleware/cache'); + const redis = getRedis(); + if (!redis) return 'disconnected'; + // ioredis and mock redis both support ping() + if (typeof redis.ping !== 'function') return 'unknown'; + const pong = await redis.ping().catch(() => null); + return pong === 'PONG' ? 'connected' : 'error'; + // eslint-disable-next-line no-unused-vars + } catch { return 'unknown'; } + })(); + + const pansouStatus = await (async () => { + try { + // Native fetch available in Node 20+ + const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search'; + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 3000); + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal }); + clearTimeout(t); + return r.ok ? 'ok' : 'degraded'; + // eslint-disable-next-line no-unused-vars + } catch { return 'unreachable'; } + })(); + + const videoParserStatus = await (async () => { + try { + // Native fetch available in Node 20+ + const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, ''); + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 3000); + const r = await fetch(url, { signal: controller.signal }); + clearTimeout(t); + return r.ok ? 'ok' : 'degraded'; + // eslint-disable-next-line no-unused-vars + } catch { return 'unreachable'; } + })(); + + const overall = dbOk && pansouStatus !== 'unreachable' + ? 'ok' + : dbOk + ? 'degraded' + : 'unhealthy'; + + res.json({ + version: APP_VERSION, + status: overall, + timestamp: new Date().toISOString(), + uptime: Math.floor(process.uptime()), + memory: process.memoryUsage().rss, + components: { + db: dbOk ? 'connected' : 'error', + redis: redisStatus, + pansou: pansouStatus, + videoParser: videoParserStatus, + }, + }); +}); + +// ============ PanSou Web UI Proxy ============ +app.use('/pansou', pansouWebProxy); + +// SPA fallback +app.use((req, res, next) => { + if (req.path.startsWith('/api/') || req.path === '/health') return next(); + res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); }); +}); + +// Global error handler +app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error('[Error]', err); + res.status(err.status || 500).json({ + error: config.nodeEnv === 'production' ? 'Internal server error' : err.message, + code: err.status || 500, + }); +}); + +// ============ Server Start ============ +async function start(): Promise { + // ── 启动前配置校验 ── + if (!checkStartup()) { + console.error('[Server] 配置校验失败,退出'); + process.exit(1); + } + + try { + getDb(); + console.log('[DB] SQLite database initialized'); + try { + const { getSystemConfig } = require('./admin/system-config.service'); + const tz = getSystemConfig('timezone'); + if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); } + } catch { console.warn('[Config] Could not set timezone, using default'); } + } catch (err) { + console.error('[DB] Failed to initialize database:', err); + process.exit(1); + } + + try { + const { getSystemConfig } = require('./admin/system-config.service'); + const redisUrl = (process.env.REDIS_URL || getSystemConfig('redis_url') || '').trim(); + if (redisUrl) { + const ok = await reconnectRedis(redisUrl); + if (ok) console.log('[Redis] Connected to', redisUrl); + else console.warn('[Redis] Connection failed, continuing without cache'); + } else { + console.log('[Redis] No REDIS_URL configured, running without cache'); + } + } catch { console.warn('[Redis] Redis not available, continuing without cache'); } + + // Cleanup scheduler + const CLEANUP_INTERVAL = 10 * 60 * 1000; + setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL); + setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000); + + // Storage info refresh scheduler — every 60 minutes + const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000; + setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL); + setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000); + + const server = app.listen(config.port, () => { + console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`); + }); + + const shutdown = async (signal: string) => { + console.log(`\n[Server] Received ${signal}, shutting down gracefully...`); + server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); }); + setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); }); + process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); }); +} + +start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); }); + +export default app; diff --git a/packages/backend/src/middleware/cache.ts b/packages/backend/src/middleware/cache.ts new file mode 100755 index 0000000..777f6f1 --- /dev/null +++ b/packages/backend/src/middleware/cache.ts @@ -0,0 +1,172 @@ +import Redis from 'ioredis'; + +let client: Redis | null = null; +let currentUrl: string = ''; + +export function getRedis(): Redis | null { + return client; +} + +export function getRedisClient(): Redis | null { + if (client) return client; + return createClient(); +} + +function createClient(url?: string): Redis | null { + const redisUrl = url || process.env.REDIS_URL || currentUrl || getSystemConfigRedisUrl(); + if (!redisUrl) return null; + currentUrl = redisUrl; + + if (client) { + try { client.disconnect(); } catch {} + client = null; + } + + client = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + retryStrategy(times: number) { + if (times > 3) return null; + return Math.min(times * 200, 2000); + }, + lazyConnect: true, + }); + + client.on('error', (err: Error) => { + console.error('[Redis] Error:', err.message); + }); + + client.on('connect', () => { + console.log('[Redis] Connected to', currentUrl); + }); + + return client; +} + +function getSystemConfigRedisUrl(): string { + try { + const { getSystemConfig } = require('./admin/system-config.service'); + return getSystemConfig('redis_url') || process.env.REDIS_URL || ''; + } catch { + return ''; + } +} + +export async function connectRedis(): Promise { + const redis = createClient(); + if (!redis) return; + try { + await redis.connect(); + } catch (err) { + console.warn('[Redis] Connection failed, running without cache'); + } +} + +export async function reconnectRedis(url: string): Promise { + try { + if (client) { + await client.quit().catch(() => {}); + client = null; + } + const redis = createClient(url); + if (!redis) return false; + await redis.connect(); + console.log('[Redis] Reconnected with new URL:', url); + return true; + } catch (err) { + console.error('[Redis] Reconnect failed:', err); + return false; + } +} + +export async function disconnectRedis(): Promise { + if (client) { + await client.quit(); + client = null; + currentUrl = ''; + console.log('[Redis] Disconnected'); + } +} + +/** + * Test a Redis URL without affecting the running client. + * @returns { ok: boolean, latency: number, info?: string } + */ +export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info?: string }> { + const start = Date.now(); + const testClient = new Redis(url, { + maxRetriesPerRequest: 1, + retryStrategy() { return null; }, + lazyConnect: true, + connectTimeout: 5000, + }); + try { + await testClient.connect(); + const pong = await testClient.ping(); + const latency = Date.now() - start; + await testClient.quit(); + return { ok: pong === 'PONG', latency, info: `响应时间 ${latency}ms` }; + } catch (err: any) { + try { await testClient.disconnect(); } catch {} + const latency = Date.now() - start; + return { ok: false, latency, info: err.message || '连接失败' }; + } +} + +export class RedisClient { + private redis: Redis; + + constructor() { + this.redis = getRedisClient() || (null as unknown as Redis); + } + + private isConnected(): boolean { + return this.redis !== null && typeof this.redis.get === 'function'; + } + + async get(key: string): Promise { + if (!this.isConnected()) return null; + try { + return await this.redis.get(key); + } catch { + return null; + } + } + + async set(key: string, value: string): Promise { + if (!this.isConnected()) return; + try { + await this.redis.set(key, value); + } catch { + // silently fail + } + } + + async setEx(key: string, ttl: number, value: string): Promise { + if (!this.isConnected()) return; + try { + await this.redis.setex(key, ttl, value); + } catch { + // silently fail + } + } + + async del(key: string): Promise { + if (!this.isConnected()) return; + try { + await this.redis.del(key); + } catch { + // silently fail + } + } + + async exists(key: string): Promise { + try { + const result = await this.redis.exists(key); + return result === 1; + } catch { + return false; + } + } +} + +export default RedisClient; diff --git a/packages/backend/src/middleware/rate-limit.ts b/packages/backend/src/middleware/rate-limit.ts new file mode 100755 index 0000000..80e0a77 --- /dev/null +++ b/packages/backend/src/middleware/rate-limit.ts @@ -0,0 +1,61 @@ +import rateLimit from 'express-rate-limit'; + +/** 公开搜索接口:较宽松 */ +export const searchLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 150, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown', + message: { error: '搜索请求过于频繁,请稍后再试', code: 429 }, +}); + +/** 管理接口(admin/*):较严格 */ +export const adminLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown', + message: { error: '操作过于频繁,请稍后再试', code: 429 }, +}); + +/** 登录接口:极严格,防暴力破解 */ +export const loginLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown', + message: { error: '登录尝试次数过多,请一分钟后重试', code: 429 }, +}); + +/** 转存/保存接口:中等等级 */ +export const saveLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => req.socket.remoteAddress ?? 'unknown', + message: { error: '转存操作过于频繁,请稍后再试', code: 429 }, +}); + +/** 获取真实客户端 IP(优先代理头) */ +function getClientIP(req: any): string { + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() + ?? req.headers['x-real-ip'] + ?? req.socket.remoteAddress + ?? 'unknown'; +} + +/** 默认全局限流(兜底,未匹配上述规则的路由) */ +const defaultLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 500, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: getClientIP, + message: { error: 'Too many requests, please try again later.', code: 429 }, +}); + +export default defaultLimiter; diff --git a/packages/backend/src/proxy/pansou-web.ts b/packages/backend/src/proxy/pansou-web.ts new file mode 100755 index 0000000..5be0fbb --- /dev/null +++ b/packages/backend/src/proxy/pansou-web.ts @@ -0,0 +1,137 @@ +import { Request, Response, NextFunction } from 'express'; +// Native fetch available in Node 20+ +import { getSystemConfig } from '../admin/system-config.service'; + +const PANSOU_UPSTREAM = 'http://pansou:80'; + +// Content types that need sub_filter path rewriting +const TEXT_TYPES = ['text/html', 'application/javascript', 'text/javascript']; + +// Hop-by-hop headers that should not be forwarded +const HOP_HEADERS = new Set([ + 'host', 'connection', 'content-length', 'transfer-encoding', + 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'upgrade', +]); + +/** + * Apply sub_filter string replacements to HTML/JS content. + * This matches what the nginx pansou.conf sub_filter does. + */ +function applySubFilter(text: string): string { + return text + // Replace HTML/JS path references: /api/ -> /pansou/api/ + .replace(/\/api\//g, '/pansou/api/') + // baseURL rewrite (Vue SPA config) + .replace(/baseURL:"\/api"/g, 'baseURL:"/pansou/api"') + .replace(/baseURL:'\/api'/g, "baseURL:'/pansou/api'") + // Static asset path rewrites + .replace(/src="\/assets\//g, 'src="/pansou/assets/') + .replace(/src='\/assets\//g, "src='/pansou/assets/") + .replace(/href="\/assets\//g, 'href="/pansou/assets/') + .replace(/href='\/assets\//g, "href='/pansou/assets/") + // Favicon path rewrite + .replace(/href="\/favicon\.ico/g, 'href="/pansou/favicon.ico') + .replace(/href='\/favicon\.ico/g, "href='/pansou/favicon.ico"); +} + +/** + * Express middleware that proxies /pansou/* requests to the PanSou web container. + * + * How it works: + * 1. Strips the /pansou prefix from the request path + * 2. Forwards the request to http://pansou:80/{path} + * 3. For HTML/JS responses, applies sub_filter path rewriting + * so that /api/ becomes /pansou/api/ and /assets/ becomes /pansou/assets/ + * 4. For static assets (CSS, images, fonts), pipes through as-is + * + * Controlled by system config key 'pansou_web_enabled' (true/false). + */ +export async function pansouWebProxy(req: Request, res: Response, _next: NextFunction): Promise { + try { + // Check if PanSou web is enabled + const enabled = getSystemConfig('pansou_web_enabled'); + if (enabled !== 'true') { + res.status(404).send('PanSou Web UI is disabled by administrator'); + return; + } + + // Build upstream URL: strip /pansou prefix + let targetPath = req.path; + targetPath = targetPath.replace(/^\/pansou/, '') || '/'; + + // Preserve query string + const queryIndex = req.url.indexOf('?'); + const query = queryIndex >= 0 ? req.url.substring(queryIndex) : ''; + const upstreamUrl = `${PANSOU_UPSTREAM}${targetPath}${query}`; + + // Build forwarded headers (filter out hop-by-hop headers) + const forwardHeaders: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_HEADERS.has(key.toLowerCase()) && value !== undefined) { + forwardHeaders[key] = Array.isArray(value) ? value.join(', ') : value; + } + } + // Override Host header to target the upstream + forwardHeaders['Host'] = 'pansou'; + // Remove Accept-Encoding so we get uncompressed content for text rewriting + forwardHeaders['accept-encoding'] = ''; + + // Forward the request + const response = await fetch(upstreamUrl, { + method: req.method as any, + headers: forwardHeaders, + body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), + redirect: 'manual', + signal: AbortSignal.timeout(30000), + }); + + const contentType = response.headers.get('content-type') || ''; + + // Set response status + res.status(response.status); + + // Handle redirects - rewrite Location header to include /pansou prefix + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (location) { + if (location.startsWith('/')) { + res.setHeader('location', '/pansou' + location); + } else { + res.setHeader('location', location); + } + } + } + + // For HTML/JS content, apply sub_filter string replacements + if (TEXT_TYPES.some(t => contentType.includes(t))) { + const text = await response.text(); + const modified = applySubFilter(text); + res.setHeader('content-type', contentType); + // Remove content-encoding since we decompressed + res.setHeader('content-length', Buffer.byteLength(modified, 'utf-8').toString()); + res.send(modified); + return; + } + + // For other content (CSS, images, fonts, etc.), pipe through as-is + const excludedHeaders = new Set([ + 'content-encoding', 'content-length', 'transfer-encoding', + 'keep-alive', 'connection', + ]); + response.headers.forEach((value, key) => { + if (!excludedHeaders.has(key.toLowerCase())) { + res.setHeader(key, value); + } + }); + + // Use buffer for reliability + const buffer = await response.arrayBuffer().then(buf => Buffer.from(buf)); + res.end(buffer); + } catch (err: any) { + console.error(`[PanSou Web Proxy] Error proxying ${req.path}:`, err.message); + if (!res.headersSent) { + res.status(502).send(`PanSou Web proxy error: ${err.message}`); + } + } +} diff --git a/packages/backend/src/routes/admin.routes.ts b/packages/backend/src/routes/admin.routes.ts new file mode 100644 index 0000000..b048162 --- /dev/null +++ b/packages/backend/src/routes/admin.routes.ts @@ -0,0 +1,615 @@ +import { Router, Request, Response } from 'express'; +// Native fetch available in Node 20+ +import fs from "fs"; +import { execSync } from 'child_process'; +import { adminLimiter, loginLimiter } from '../middleware/rate-limit'; +import { getSaveRecords } from '../cloud/cloud.service'; +import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie } from '../cloud/credential.service'; +// Note: check-in routes were removed (sign-in feature removed) +import { getAllCloudTypes } from '../cloud/cloud-types.service'; +import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service'; +import { getStats } from '../admin/stats.service'; +import { getAllSystemConfigs, updateSystemConfig, updateSystemConfigs, getSystemConfig } from '../admin/system-config.service'; +import { testProxyConnection } from '../utils/proxy-agent'; +import { getDb } from '../database/database'; +import { reconnectRedis, testRedisConnection } from '../middleware/cache'; +import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; +import { BaiduDriver } from '../cloud/drivers/baidu.driver'; + +const router = Router(); + +// ═══════════════════════════════════════ +// Public routes (no auth required) +// ═══════════════════════════════════════ + +/** + * POST /api/admin/login + * Admin login + */ +router.post('/admin/login', loginLimiter, (req: Request, res: Response) => { + try { + const { username, password } = req.body; + if (!username || !password) { + res.status(400).json({ error: 'Username and password are required' }); + return; + } + + const token = login(username, password); + if (!token) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + + res.json({ token }); + } catch (err: any) { + console.error('[Login] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/admin/cloud-types + * List all cloud types (public, read-only). + */ +router.get('/admin/cloud-types', (_req: Request, res: Response) => { + try { + const types = getAllCloudTypes(); + res.json({ types }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +// ═══════════════════════════════════════ +// QR Login routes (no auth — user not logged in yet) +// MUST be before authMiddleware! +// ═══════════════════════════════════════ + +// ===== 夸克扫码登录 ===== +router.post('/admin/quark/qr-login/start', async (_req: Request, res: Response) => { + try { + const result = await startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get('/admin/quark/qr-login/:sessionId/status', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result = await getQrLoginStatus(sessionId); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post('/admin/quark/qr-login/:sessionId/cancel', async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + await cancelQrLogin(sessionId); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ===== 百度扫码登录 ===== +router.post("/admin/baidu/qr-login/start", async (_req: Request, res: Response) => { + try { + const result = await BaiduDriver.startQrLogin(); + res.json({ ok: true, ...result }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.get("/admin/baidu/qr-login/:sessionId/status", async (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const result: any = await BaiduDriver.getQrLoginStatus(sessionId); + // Map to frontend-expected format (frontend reads data.cookie) + res.json({ + ok: true, + status: result.status, + cookie: result.cookie || result.access_token || "", + nickname: result.nickname || "", + storage_used: result.storage_used || "", + storage_total: result.storage_total || "", + }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res: Response) => { + try { + BaiduDriver.cancelQrLogin(req.params.sessionId as string); + } catch {} + res.json({ ok: true }); +}); + +// ═══════════════════════════════════════ +// Auth wall — all routes below require JWT +// ═══════════════════════════════════════ +router.use('/admin', authMiddleware); + +// ═══════════════════════════════════════ +// Cloud Configs CRUD +// ═══════════════════════════════════════ + +/** GET /api/admin/cloud-configs — list all cloud configs */ +router.get('/admin/cloud-configs', (_req: Request, res: Response) => { + try { + const configs = getCloudConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to fetch cloud configs' }); + } +}); + +/** POST /api/admin/cloud-configs — create or smart-replace a cloud config */ +router.post('/admin/cloud-configs', (req: Request, res: Response) => { + try { + const data = req.body; + if (!data.cloud_type) { + res.status(400).json({ error: 'cloud_type is required' }); + return; + } + // Normalize is_active: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_active === 'boolean') data.is_active = data.is_active ? 1 : 0; + // Normalize is_transfer_enabled: frontend sends boolean, SQLite needs 0/1 + if (typeof data.is_transfer_enabled === 'boolean') data.is_transfer_enabled = data.is_transfer_enabled ? 1 : 0; + const saved = saveCloudConfig(data); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to save cloud config' }); + } +}); + +/** PUT /api/admin/cloud-configs/:id — update an existing cloud config */ +router.put('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const existing = getCloudConfigById(id); + if (!existing) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + const saved = saveCloudConfig({ ...req.body, id }); + res.json(saved); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update cloud config' }); + } +}); + +/** DELETE /api/admin/cloud-configs/:id */ +router.delete('/admin/cloud-configs/:id', (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string); + const ok = deleteCloudConfig(id); + if (!ok) { + res.status(404).json({ error: 'Cloud config not found' }); + return; + } + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to delete cloud config' }); + } +}); + +/** POST /api/admin/cloud-configs/:type/test — test cloud connection (by type or id) */ +router.post('/admin/cloud-configs/:type/test', async (req: Request, res: Response) => { + try { + const type = req.params.type as string; + const { cookie, id } = req.body; + + // If cookie is provided directly, test with it (for new configs not yet saved) + if (cookie) { + const result = await testCloudConnectionWithCookie(type, cookie); + res.json(result); + return; + } + + // Otherwise test by config id + if (id) { + const result = await testCloudConnection(parseInt(id)); + res.json(result); + return; + } + + res.status(400).json({ success: false, message: 'Provide either cookie or id' }); + } catch (err: any) { + res.status(500).json({ success: false, message: err.message || 'Connection test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Stats +// ═══════════════════════════════════════ + +/** GET /api/admin/stats */ +router.get('/admin/stats', (req: Request, res: Response) => { + try { + const days = req.query.days ? parseInt(req.query.days as string) : 7; + const stats = getStats(days); + res.json(stats); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get stats' }); + } +}); + +// ═══════════════════════════════════════ +// Save Records (转存日志) +// ═══════════════════════════════════════ + +/** GET /api/admin/save-records */ +router.get('/admin/save-records', (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 20; + const startDate = req.query.startDate as string | undefined; + const endDate = req.query.endDate as string | undefined; + const status = req.query.status as string | undefined; + const sourceType = req.query.sourceType as string | undefined; + const keyword = req.query.keyword as string | undefined; + const result = getSaveRecords(page, pageSize, startDate, endDate, status, sourceType, keyword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get save records' }); + } +}); + +// ═══════════════════════════════════════ +// System Configs +// ═══════════════════════════════════════ + +/** GET /api/admin/system-configs */ +router.get('/admin/system-configs', (_req: Request, res: Response) => { + try { + const configs = getAllSystemConfigs(); + res.json(configs); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get system configs' }); + } +}); + +/** PUT /api/admin/system-configs — batch update */ +router.put('/admin/system-configs', (req: Request, res: Response) => { + try { + const { entries } = req.body; + if (!entries || !Array.isArray(entries)) { + res.status(400).json({ error: 'entries array is required' }); + return; + } + updateSystemConfigs(entries); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to update system configs' }); + } +}); + +// ═══════════════════════════════════════ +// Cloud Types Toggle +// ═══════════════════════════════════════ + +/** PUT /api/admin/cloud-types — toggle cloud type enabled/disabled */ +router.put('/admin/cloud-types', (req: Request, res: Response) => { + try { + const { type, enabled } = req.body; + if (!type) { + res.status(400).json({ error: 'type is required' }); + return; + } + const db = getDb(); + db.prepare( + `INSERT INTO system_configs (key, value, description) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(`cloud_type_${type}_enabled`, enabled ? '1' : '0', `Enable/disable ${type} cloud drive`); + res.json({ success: true }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to toggle cloud type' }); + } +}); + +// ═══════════════════════════════════════ +// Change Password +// ═══════════════════════════════════════ + +/** POST /api/admin/change-password */ +router.post('/admin/change-password', (req: Request, res: Response) => { + try { + const { oldPassword, newPassword } = req.body; + if (!oldPassword || !newPassword) { + res.status(400).json({ error: 'Both old and new passwords are required' }); + return; + } + // Get username from JWT + const authHeader = req.headers.authorization || ''; + const token = authHeader.replace('Bearer ', ''); + const payload = verifyToken(token); + if (!payload) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + const result = changePassword(payload.username, oldPassword, newPassword); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to change password' }); + } +}); + +// ═══════════════════════════════════════ +// DB Status +// ═══════════════════════════════════════ + +/** GET /api/admin/db-status */ +router.get('/admin/db-status', async (_req: Request, res: Response) => { + try { + const dbFile = getSystemConfig('db_path') || ''; + let dbSize = 'N/A'; + if (dbFile) { + try { + const stats = fs.statSync(dbFile); + dbSize = (stats.size / 1024 / 1024).toFixed(2) + ' MB'; + } catch {} + } + + const db = getDb(); + const counts = { + save_records: (db.prepare('SELECT COUNT(*) as c FROM save_records').get() as any)?.c || 0, + search_stats: (db.prepare('SELECT COUNT(*) as c FROM search_stats').get() as any)?.c || 0, + system_configs: (db.prepare('SELECT COUNT(*) as c FROM system_configs').get() as any)?.c || 0, + cloud_configs: (db.prepare('SELECT COUNT(*) as c FROM cloud_configs').get() as any)?.c || 0, + content_cache: (db.prepare('SELECT COUNT(*) as c FROM content_cache').get() as any)?.c || 0, + }; + + // Redis status + let redis_status = 'disconnected'; + let redis_url = getSystemConfig('redis_url') || ''; + try { + const testResult = await testRedisConnection(redis_url); + redis_status = testResult.ok ? 'connected' : 'disconnected'; + } catch { + redis_status = 'error'; + } + + res.json({ + db_size: dbSize, + db_path: dbFile, + ...counts, + redis_status, + redis_url, + }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Failed to get DB status' }); + } +}); + +// ═══════════════════════════════════════ +// Test Redis Connection +// ═══════════════════════════════════════ + +/** POST /api/admin/test-redis */ +router.post('/admin/test-redis', async (req: Request, res: Response) => { + try { + const { url } = req.body; + if (!url) { + res.status(400).json({ ok: false, info: 'Redis URL is required' }); + return; + } + const result = await testRedisConnection(url); + res.json(result); + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'Redis test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Test External Service +// ═══════════════════════════════════════ + +/** POST /api/admin/test-external-service */ +router.post('/admin/test-external-service', async (req: Request, res: Response) => { + try { + const { type, url, token } = req.body; + const start = Date.now(); + + switch (type) { + case 'pansou': { + const pansouUrl = url || getSystemConfig('pansou_url') || ''; + if (!pansouUrl) { + res.json({ ok: false, info: 'PanSou URL not configured' }); + return; + } + const response = await fetch(pansouUrl + '/api/health', { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + res.json({ + ok: response.ok && data?.status === 'ok', + latency, + info: response.ok ? `连接成功 (${data?.channels_count || 0} 频道, ${data?.plugin_count || 0} 插件)` : '连接失败', + }); + break; + } + case 'video_parser': { + const parserUrl = url || getSystemConfig('video_parser_url') || ''; + if (!parserUrl) { + res.json({ ok: false, info: 'Video Parser URL not configured' }); + return; + } + const response = await fetch(parserUrl + '/health', { signal: AbortSignal.timeout(8000) }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'tmdb': { + const tmdbToken = token || getSystemConfig('tmdb_api_key') || ''; + if (!tmdbToken) { + res.json({ ok: false, info: 'TMDB API Key not configured' }); + return; + } + const response = await fetch('https://api.themoviedb.org/3/configuration', { + headers: { Authorization: `Bearer ${tmdbToken}` }, + signal: AbortSignal.timeout(8000), + }); + const latency = Date.now() - start; + res.json({ + ok: response.ok, + latency, + info: response.ok ? '连接成功' : `HTTP ${response.status}`, + }); + break; + } + case 'proxy': { + const proxyUrl = url || getSystemConfig('search_proxy_url') || ''; + if (!proxyUrl) { + res.json({ ok: false, info: 'Proxy URL not configured' }); + return; + } + const result = await testProxyConnection(proxyUrl); + res.json(result); + break; + } + case 'ip_geo': { + const geoUrl = url || getSystemConfig('ip_geo_api_url') || ''; + if (!geoUrl) { + res.json({ ok: false, info: '请先输入 IP 归属地查询 API 地址' }); + return; + } + const testUrl = geoUrl.replace('{ip}', '8.8.8.8'); + const response = await fetch(testUrl, { signal: AbortSignal.timeout(8000) }); + const data: any = await response.json(); + const latency = Date.now() - start; + const valid = !!(data?.country || data?.region || data?.city || data?.countryCode); + res.json({ ok: valid, latency, info: valid ? '连接成功' : '响应格式不符' }); + break; + } + default: + res.json({ ok: false, info: `Unknown service type: ${type}` }); + } + } catch (err: any) { + res.status(500).json({ ok: false, info: err.message || 'External service test failed' }); + } +}); + +// ═══════════════════════════════════════ +// Pansou Info & Update +// ═══════════════════════════════════════ + +/** GET /api/admin/pansou-info — pansou health + version + update check */ +router.get('/admin/pansou-info', async (_req: Request, res: Response) => { + try { + const baseUrl = getSystemConfig('pansou_url') || ''; + if (!baseUrl) { + res.json({ status: 'disconnected', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '' }); + return; + } + + // Fetch PanSou health + const healthUrl = baseUrl + '/api/health'; + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(8000) }); + const healthData: any = await response.json(); + const channelCount = healthData.channels_count || 0; + const pluginCount = healthData.plugin_count || 0; + + // Derive disk count from channel names + const driveKeywords = ['aliyun', 'baidu', 'quark', '115', 'pikpak', 'xunlei', 'uc', '123', '139', '189', 'tianyi', 'netease']; + const drives = new Set(); + for (const ch of (healthData.channels || [])) { + for (const kw of driveKeywords) { + if (ch.toLowerCase().includes(kw)) { drives.add(kw); break; } + } + } + const diskCount = drives.size || 5; + + // Get local version from docker label + let version = ''; + let hasUpdate = false; + let latestVersion = ''; + try { + const created = execSync( + `docker inspect CloudSearch_PanSou --format '{{index .Config.Labels "org.opencontainers.image.created"}}'`, + { timeout: 5000, encoding: 'utf8' } + ).trim(); + version = created ? created.slice(0, 10) : ''; + + // Check update cache + const cacheFile = '/tmp/pansou-update-cache.json'; + let cache: any = null; + try { cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8') || 'null'); } catch {} + const threeDays = 3 * 24 * 3600 * 1000; + + if (!cache || (Date.now() - cache.checkedAt) > threeDays) { + // Check GHCR for latest version + try { + const tokenRes = await fetch( + 'https://ghcr.io/token?scope=repository:fish2018/pansou-web:pull&service=ghcr.io' + ); + const ghcrToken = (await tokenRes.json() as any).token; + const manifestRes = await fetch( + 'https://ghcr.io/v2/fish2018/pansou-web/manifests/latest', + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' } } + ); + const manifestList: any = await manifestRes.json(); + const amd64 = manifestList.manifests?.find((m: any) => m.platform?.architecture === 'amd64' && m.platform?.os === 'linux'); + if (amd64) { + const blobRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/manifests/${amd64.digest}`, + { headers: { Authorization: `Bearer ${ghcrToken}`, Accept: 'application/vnd.oci.image.manifest.v1+json' } } + ); + const blobData: any = await blobRes.json(); + const cfgDigest = blobData.config?.digest; + if (cfgDigest) { + const cfgRes = await fetch( + `https://ghcr.io/v2/fish2018/pansou-web/blobs/${cfgDigest}`, + { headers: { Authorization: `Bearer ${ghcrToken}` } } + ); + const cfgData: any = await cfgRes.json(); + const remoteCreated = cfgData.config?.Labels?.['org.opencontainers.image.created']; + if (remoteCreated) { + latestVersion = remoteCreated.slice(0, 10); + if (version && latestVersion !== version) hasUpdate = true; + } + } + } + } catch {} + fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), hasUpdate, latestVersion })); + } else { + hasUpdate = cache.hasUpdate; + latestVersion = cache.latestVersion; + } + } catch {} + + res.json({ + status: response.ok ? 'connected' : 'disconnected', + channelCount, + pluginCount, + diskCount, + version, + hasUpdate, + latestVersion, + }); + } catch (err: any) { + res.json({ status: 'error', channelCount: 0, pluginCount: 0, diskCount: 0, version: '', hasUpdate: false, latestVersion: '', error: err.message }); + } +}); + +/** POST /api/admin/update-pansou — pull latest pansou image + recreate container */ +router.post('/admin/update-pansou', async (_req: Request, res: Response) => { + try { + execSync('docker pull ghcr.io/fish2018/pansou-web:latest', { timeout: 120000 }); + execSync('docker compose -p cloudsearch -f /app/docker-compose.yml up -d pansou', { timeout: 60000 }); + try { fs.unlinkSync('/tmp/pansou-update-cache.json'); } catch {} + res.json({ success: true, message: 'PanSou 更新成功' }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message || 'PanSou 更新失败' }); + } +}); + +export default router; diff --git a/packages/backend/src/routes/cleanup.routes.ts b/packages/backend/src/routes/cleanup.routes.ts new file mode 100644 index 0000000..da29bf3 --- /dev/null +++ b/packages/backend/src/routes/cleanup.routes.ts @@ -0,0 +1,87 @@ +import { Router, Request, Response } from 'express'; +import { runFullCleanup, emptyAllTrash } from '../cloud/cleanup.service'; + +const router = Router(); + +// ============ Cleanup & Storage Management ============ + +/** + * POST /api/admin/cleanup/run + * Manually trigger a cleanup cycle: + * - Trash old date folders from cloud drives + * - Delete old save_records + * - Empty recycle bin + */ +router.post('/admin/cleanup/run', async (_req: Request, res: Response) => { + try { + const stats = await runFullCleanup(); + res.json({ + success: stats.errors.length === 0, + files_trashed: stats.filesTrashed, + logs_deleted: stats.logsDeleted, + trash_emptied: stats.trashEmptied, + errors: stats.errors, + message: stats.errors.length === 0 + ? `✅ 清理完成:移入回收站 ${stats.filesTrashed} 个文件夹,删除 ${stats.logsDeleted} 条日志,清空回收站${stats.trashEmptied ? '✓' : '-'}` + : `清理完成,但有 ${stats.errors.length} 个错误`, + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * POST /api/admin/cleanup/empty-trash + * Empty recycle bin for all cloud drives (permanently delete, frees space). + */ +router.post('/admin/cleanup/empty-trash', async (_req: Request, res: Response) => { + try { + const result = await emptyAllTrash(); + res.json({ + success: result.errors.length === 0, + emptied: result.emptied, + errors: result.errors, + message: result.emptied + ? '✅ 回收站已清空,存储空间已释放' + : (result.errors.length > 0 ? `清空回收站部分失败:${result.errors.join('; ')}` : '没有可清空的网盘'), + }); + } catch (err: any) { + res.status(500).json({ success: false, error: err.message }); + } +}); + + +/** + * Extract genre tags from search result titles. + */ +function extractTagsFromResults(results: any[], keyword: string): string[] { + const tags: string[] = []; + if (keyword) tags.push(keyword); + + const genreKeywords: Record = { + '动画': '动画', '动漫': '动画', '国漫': '国漫', + '剧场版': '剧场版', '年番': '年番', + '动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻', + '仙侠': '仙侠', '古装': '古装', '爱情': '爱情', + '科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑', + '恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情', + '冒险': '冒险', '战争': '战争', '武侠': '武侠', + '纪录': '纪录片', '真人': '真人秀', '短片': '短片', + }; + + const seen = new Set(); + for (const r of results) { + const title = (r.title || r.note || '') as string; + for (const [key, val] of Object.entries(genreKeywords)) { + if (title.includes(key) && !seen.has(val)) { + seen.add(val); + tags.push(val); + } + } + } + + return tags; +} + + +export default router; \ No newline at end of file diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts new file mode 100755 index 0000000..7a83269 --- /dev/null +++ b/packages/backend/src/routes/index.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import searchRoutes from './search.routes'; +import adminRoutes from './admin.routes'; +import uploadRoutes from './upload.routes'; +import cleanupRoutes from './cleanup.routes'; + +const router = Router(); + +router.use(searchRoutes); +router.use(adminRoutes); +router.use(uploadRoutes); +router.use(cleanupRoutes); + +export default router; diff --git a/packages/backend/src/routes/search.routes.ts b/packages/backend/src/routes/search.routes.ts new file mode 100644 index 0000000..94c7dd1 --- /dev/null +++ b/packages/backend/src/routes/search.routes.ts @@ -0,0 +1,630 @@ +import { Router, Request, Response } from 'express'; +// Native fetch available in Node 20+ +import { searchLimiter, saveLimiter } from '../middleware/rate-limit'; +import { detectIntent } from '../intent/intent.service'; +import { search, applyTitleFilter } from '../search/search.service'; +import { getRankings, getHotKeywords, getCategorizedRankings } from '../search/rankings.service'; +import { parseVideo } from '../video/video.service'; +import { saveFromShare } from '../cloud/cloud.service'; +import { getEnabledCloudTypeSet } from '../cloud/cloud-types.service'; +import { getSystemConfig } from '../admin/system-config.service'; +import { verifyToken } from '../admin/auth.service'; +import { LinkValidator } from '../validation/link-validator.service'; +import { getContentInfo } from '../content/content.service'; +import { detectCloudType } from '../config/cloud-labels'; +import { CLOUD_LABELS, CLOUD_COLORS } from '../config/cloud-labels'; +import { getDb } from '../database/database'; + +const router = Router(); + +// ============ Search & Query ============ + +/** + * POST /api/query + * Intent recognition + execution + */ +router.post('/query', searchLimiter, async (req: Request, res: Response) => { + try { + const { input, q } = req.body; + const query = input || q; + if (!query || typeof query !== 'string') { + res.status(400).json({ error: 'Input is required' }); + return; + } + + const intent = detectIntent(query); + const ip = req.ip || req.socket.remoteAddress || ''; + + switch (intent.type) { + case 'SEARCH': { + const result = await search(intent.cleanInput, 1, ip); + + // Pass through: use all results, group by cloud type + const allResults = result.results || []; + + // Transform to frontend-friendly format + let formatted = (allResults || []).map((item: any, idx: number) => ({ + id: `search_${idx}`, + title: filterTitle(item.title || item.content || ''), + description: item.content || '', + share_url: item.url || '', + cloud_type: detectCloudType(item.url || ''), + file_size: '', + update_time: item.datetime || '', + source: item.source || '', + file_id: '', + cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '', + password: item.password || '', + })); + + // Filter out expired/invalid links + formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url)); + + // Filter by enabled cloud types (admin-configurable per-type toggle) + // Skip filter if search_all_channels is enabled + const searchAllChannels = getSystemConfig('search_all_channels') === 'true'; + if (!searchAllChannels) { + const enabledSet = getEnabledCloudTypeSet(); + formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type)); + } + + const contentQuery = intent.cleanInput || query; + const contentInfo = await getContentInfo(contentQuery).catch(() => null); + const extractedTags = extractTagsFromResults(formatted, contentQuery); + const linkValidationEnabled = getSystemConfig('link_validation_enabled') !== 'false'; + + // Set up streaming response (NDJSON) + res.setHeader('Content-Type', 'application/x-ndjson'); + res.setHeader('X-Accel-Buffering', 'no'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + // 0. Send searching signal immediately so frontend shows feedback + res.write(JSON.stringify({ type: 'searching' }) + '\n'); + + // 0.5 Query local DB for previously saved resources matching keyword + const savedResults = getSavedResources(intent.cleanInput); + if (savedResults.length > 0) { + res.write(JSON.stringify({ + type: 'saved', + results: savedResults, + total: savedResults.length, + }) + '\n'); + } + + // 1. Send stats immediately + const fallbackImage = getSystemConfig('search_fallback_image') || ''; + const siteLogo = getSystemConfig('site_logo') || ''; + const siteNameInStats = getSystemConfig('site_name') || 'CloudSearch'; + const siteDisclaimer = getSystemConfig('site_disclaimer') || ''; + const siteMarquee = getSystemConfig('site_marquee') || ''; + const statsPayload = { + type: 'stats', + total: formatted.length, + channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type), + content_info: contentInfo, + content_tags: extractedTags, + link_validation: linkValidationEnabled, + fallback_image: fallbackImage, + site_logo: siteLogo, + site_name: siteNameInStats, + site_disclaimer: siteDisclaimer, + site_marquee: siteMarquee, + }; + res.write(JSON.stringify(statsPayload) + '\n'); + + // 2. Validate links — per-type grouping, newest-first, per-type cap from config + if (linkValidationEnabled) { + const validator = new LinkValidator(); + const resultLimit = parseInt(getSystemConfig('search_result_limit') || '10', 10); + const MAX_VALID_PER_TYPE = Math.min(100, Math.max(1, resultLimit)); // configurable, 1-100 + const MAX_TOTAL_VALID = MAX_VALID_PER_TYPE * 6; // up to 6 cloud types + const pool = validator['pool']; // concurrency: 10 + + // Group formatted results by cloud_type, then sort each group by time desc + const byType: Record = {}; + for (const item of formatted) { + const ct = item.cloud_type || 'others'; + if (!byType[ct]) byType[ct] = []; + byType[ct].push(item); + } + + // Sort each group by update_time descending (newest first) + for (const ct of Object.keys(byType)) { + byType[ct].sort((a: any, b: any) => { + const ta = a.update_time || ''; + const tb = b.update_time || ''; + if (!ta && !tb) return 0; + if (!ta) return 1; + if (!tb) return -1; + return tb.localeCompare(ta); + }); + } + + // Build a round-robin validation queue: interleave items from each type + // to give fair priority across all cloud types + const typeOrder = ['quark', 'baidu', 'aliyun', '115', 'tianyi', '123pan', 'uc', 'xunlei', 'pikpak', 'magnet', 'ed2k', 'others']; + const sortedTypes = typeOrder.filter(ct => byType[ct] && byType[ct].length > 0); + // Sort by total count descending so types with more results get more validation slots + sortedTypes.sort((a, b) => (byType[b]?.length || 0) - (byType[a]?.length || 0)); + + const validationQueue: { item: any; type: string }[] = []; + const maxLen = Math.max(...sortedTypes.map(ct => byType[ct].length), 0); + for (let i = 0; i < maxLen; i++) { + for (const ct of sortedTypes) { + if (i < byType[ct].length) { + validationQueue.push({ item: byType[ct][i], type: ct }); + } + } + } + + const validResults: any[] = []; + const perTypeValid: Record = {}; + let totalValid = 0; + let totalInvalid = 0; + let totalChecked = 0; + const unknownItemIds: number[] = []; // IDs that got 'unknown' from PanSou + + // Pass 1: PanSou-only validation + const tasks = validationQueue.map(({ item, type }) => pool.run(async () => { + // Stop if we've hit overall cap or per-type cap + if (totalValid >= MAX_TOTAL_VALID) return; + if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) return; + + totalChecked++; + try { + const vr = await validator.validate(item.share_url, item.cloud_type); + // 'unknown' = PanSou couldn't determine → treat as valid for now + if (vr.status === 'valid' || vr.status === 'unknown') { + if (vr.status === 'unknown') { + unknownItemIds.push(item.id); + } + if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) { + validResults.push(item); + perTypeValid[type] = (perTypeValid[type] || 0) + 1; + totalValid++; + res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message }) + '\n'); + } + } else { + totalInvalid++; + res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message }) + '\n'); + } + } catch { + if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) { + validResults.push(item); + perTypeValid[type] = (perTypeValid[type] || 0) + 1; + totalValid++; + } + res.write(JSON.stringify({ type: 'result', id: item.id, valid: true }) + '\n'); + } + })); + await Promise.all(tasks); + + // Pass 2: If PanSou didn't provide enough valid results, validate + // uncertain items with local fallback (external API calls) + if (totalValid < MAX_TOTAL_VALID && unknownItemIds.length > 0) { + const unknownItems = validationQueue.filter(({ item }) => unknownItemIds.includes(item.id)); + for (const { item, type } of unknownItems) { + if (totalValid >= MAX_TOTAL_VALID) break; + if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) break; + try { + const vr = await validator.validateWithLocalFallback(item.share_url, item.cloud_type); + if (vr.status === 'valid') { + // Already in validResults from pass 1, just count it again + perTypeValid[type] = (perTypeValid[type] || 0) + 1; + totalValid++; + res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message + ' (本地确认)' }) + '\n'); + } else if (vr.status === 'invalid') { + // Remove from validResults — was previously included as unknown + const idx = validResults.findIndex(r => r.id === item.id); + if (idx >= 0) { + validResults.splice(idx, 1); + perTypeValid[type] = Math.max(0, (perTypeValid[type] || 1) - 1); + totalValid--; + } + totalInvalid++; + res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message + ' (本地确认失效)' }) + '\n'); + } + } catch { + // Keep as-is (already treated as valid from pass 1) + } + } + } + + const skippedCount = validationQueue.length - totalChecked; + + res.write(JSON.stringify({ + type: 'complete', + results: validResults, + channels: groupResultsByChannel(validResults, (item: any) => item.cloud_type), + total: validResults.length, + filtered: totalInvalid, + per_type: perTypeValid, + skipped: skippedCount, + }) + '\n'); + } else { + // No validation - just send all results + res.write(JSON.stringify({ + type: 'complete', + results: formatted, + channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type), + total: formatted.length, + filtered: 0, + }) + '\n'); + } + + res.end(); + break; + } + case 'VIDEO_PARSE': { + const videoInfo = await parseVideo(intent.cleanInput); + res.json({ intent: intent.type, platform: intent.platform, data: videoInfo }); + break; + } + case 'CLOUD_SAVE': { + const result = await saveFromShare(intent.cleanInput, intent.platform || '', undefined, req.ip); + res.json({ intent: intent.type, platform: intent.platform, ...result }); + break; + } + default: + res.status(400).json({ error: 'Unknown intent type' }); + } + } catch (err: any) { + console.error('[Query] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/search + * Search with optional link validation filtering + */ +router.get('/search', searchLimiter, async (req: Request, res: Response) => { + try { + const keyword = (req.query.q || req.query.kw) as string; + const page = parseInt(req.query.page as string || '1', 10); + const ip = req.ip || req.socket.remoteAddress || ''; + + if (!keyword) { + res.status(400).json({ error: 'Query parameter "q" is required' }); + return; + } + + const result = await search(keyword, page, ip); + + // Pass through: use all results + const allResults = result.results || []; + + // Transform to frontend format + let formatted = (allResults || []).map((item: any) => ({ + id: item.id || '', + title: filterTitle(item.title || item.content || ''), + description: item.content || item.snippet || '', + share_url: item.url || '', + cloud_type: detectCloudType(item.url || ''), + file_size: '', + source: item.source || '', + datetime: item.datetime || '', + cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '', + password: item.password || '', + })); + + // Filter out expired/invalid links + const expiredCount = formatted.filter(r => r.share_url && isExpiredShareLink(r.share_url)).length; + formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url)); + + // Filter by enabled cloud types (admin-configurable per-type toggle) + const enabledSet = getEnabledCloudTypeSet(); + formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type)); + + // Return results immediately without blocking validation + const channels = groupResultsByChannel(formatted, (item: any) => + detectCloudType(item.url || '') + ); + + res.json({ + results: formatted, + channels, + total: formatted.length, + filtered: expiredCount, + link_validation: false, + }); + } catch (err: any) { + console.error('[Search] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * Load title filter rules from DB and apply to a title. + */ +function filterTitle(title: string): string { + const rules = getSystemConfig('title_filter_rules') || ''; + return applyTitleFilter(title, rules); +} + +// detectCloudType is imported from config/cloud-labels + +// 检测失效的分享链接(支持多种模式) +function isExpiredShareLink(url: string): boolean { + if (!url) return false; + + // 空链接/纯片段(无实际链接内容) + if (url.startsWith('#') || url.length < 10) return true; + + // PanSou 有时返回残缺链接如 "/s/xxx" 或只有 "#/list/share" + if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('http')) return true; + + // 夸克链接格式校验 + if (url.includes('pan.quark.cn')) { + const baseUrl = url.split('#')[0]; // 去掉 hash 路由片段 + // 有效格式必须是 pan.quark.cn/s/xxxxxx + if (!/pan\.quark\.cn\/s\/[a-zA-Z0-9]+/.test(baseUrl)) return true; + } + + // 百度网盘常见失效格式 + if (url.includes('pan.baidu.com') && /share\/init\?surl=$/.test(url)) return true; + + // 阿里云盘失效格式(短到异常的链接) + if (url.includes('aliyundrive.com') && url.length < 30) return true; + + return false; +} + +/** + * Group search results into channels by cloud type. + * Each channel: { cloud_type, label, color, count, items } + */ + +function groupResultsByChannel(results: any[], getCloudType?: (item: any) => string): any[] { + const groups: Record = {}; + const order: Record = { + quark: 1, baidu: 2, aliyun: 3, '115': 4, + tianyi: 5, '123pan': 6, uc: 7, xunlei: 8, + pikpak: 9, magnet: 10, ed2k: 11, others: 12, + }; + for (const item of results) { + const ct = getCloudType ? getCloudType(item) : (item.source || detectCloudType(item.url || '') || 'others'); + if (!groups[ct]) groups[ct] = []; + groups[ct].push(item); + } + return Object.entries(groups) + .sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99)) + .map(([cloud_type, items]) => ({ + cloud_type, + label: (CLOUD_LABELS as any)[cloud_type] || cloud_type, + color: (CLOUD_COLORS as any)[cloud_type] || '#95a5a6', + count: items.length, + items, + })); +} + +// ============ Video ============ +// ============ Video ============ + +/** + * POST /api/video/parse + * Parse a video URL + */ +router.post('/video/parse', async (req: Request, res: Response) => { + try { + const { url } = req.body; + if (!url) { + res.status(400).json({ error: 'URL is required' }); + return; + } + + const videoInfo = await parseVideo(url); + res.json(videoInfo); + } catch (err: any) { + console.error('[Video] Parse error:', err); + res.status(500).json({ error: err.message || 'Failed to parse video' }); + } +}); + +// ============ Cloud Save ============ +// ============ Cloud Save ============ + +/** + * POST /api/save + * Save a share link to a specific cloud + */ +router.post('/save', saveLimiter, async (req: Request, res: Response) => { + try { + // Support both formats: + // 1. Backend-style: { url, cloudType } + // 2. Frontend-style: { source: { share_url }, target_cloud } + const url = req.body.url || req.body.source?.share_url || req.body.source?.url; + const cloudType = req.body.cloudType || req.body.target_cloud || (req.body.source as any)?.cloud_type; + const sourceTitle = req.body.source_title || req.body.source?.title || req.body.title; + if (!url || !cloudType) { + res.status(400).json({ error: 'URL and cloudType/cloud_type are required' }); + return; + } + + const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || ''; + const result = await saveFromShare(url, cloudType, sourceTitle, ip); + res.json(result); + } catch (err: any) { + console.error('[Save] Error:', err); + res.status(500).json({ error: err.message || 'Failed to save to cloud' }); + } +}); + +/** + * POST /api/video/save-to-cloud + * Save a video to cloud + */ +router.post('/video/save-to-cloud', saveLimiter, async (req: Request, res: Response) => { + try { + const { videoUrl, cloudType, title } = req.body; + if (!videoUrl || !cloudType) { + res.status(400).json({ error: 'videoUrl and cloudType are required' }); + return; + } + + const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || ''; + const result = await saveFromShare(videoUrl, cloudType, title, ip); + res.json(result); + } catch (err: any) { + console.error('[Video] Save-to-cloud error:', err); + res.status(500).json({ error: err.message || 'Failed to save video to cloud' }); + } +}); + +// ============ Rankings ============ +// ============ Rankings ============ + +/** + * GET /api/rankings + * Get search rankings + */ +router.get('/rankings', async (_req: Request, res: Response) => { + try { + const rankings = await getRankings(); + res.json(rankings); + } catch (err: any) { + console.error('[Rankings] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/rankings/hot + * Get hot keywords + */ +router.get('/rankings/hot', async (_req: Request, res: Response) => { + try { + const keywords = await getHotKeywords(); + res.json(keywords); + } catch (err: any) { + console.error('[Hot] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/rankings/categorized + * Get categorized rankings (hot + newest per category), cached for 12h + */ +router.get('/rankings/categorized', async (_req: Request, res: Response) => { + try { + const data = await getCategorizedRankings(); + res.json(data); + } catch (err: any) { + console.error('[Categorized] Error:', err); + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/site-config + * Public site configuration (no auth required). + */ +router.get('/site-config', (_req: Request, res: Response) => { + try { + const siteLogo = getSystemConfig('site_logo') || ''; + const siteName = getSystemConfig('site_name') || 'CloudSearch'; + const fallbackImage = getSystemConfig('search_fallback_image') || ''; + const siteDisclaimer = getSystemConfig('site_disclaimer') || ''; + const siteMarquee = getSystemConfig('site_marquee') || ''; + res.json({ site_logo: siteLogo, site_name: siteName, search_fallback_image: fallbackImage, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee }); + } catch (err: any) { + res.status(500).json({ error: err.message || 'Internal server error' }); + } +}); + +/** + * GET /api/me + * Get current user info from token (public, no auth middleware). + */ +router.get('/me', (req: Request, res: Response) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.json({ loggedIn: false }); + return; + } + const token = authHeader.split(' ')[1]; + const payload = verifyToken(token); + if (!payload) { + res.json({ loggedIn: false }); + return; + } + res.json({ loggedIn: true, id: payload.id, username: payload.username }); + } catch (err: any) { + res.json({ loggedIn: false }); + } +}); + +// ============ Admin ============ + + +/** + * Extract genre tags from search result titles. + */ +function extractTagsFromResults(results: any[], keyword: string): string[] { + const tags: string[] = []; + if (keyword) tags.push(keyword); + + const genreKeywords: Record = { + '动画': '动画', '动漫': '动画', '国漫': '国漫', + '剧场版': '剧场版', '年番': '年番', + '动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻', + '仙侠': '仙侠', '古装': '古装', '爱情': '爱情', + '科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑', + '恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情', + '冒险': '冒险', '战争': '战争', '武侠': '武侠', + '纪录': '纪录片', '真人': '真人秀', '短片': '短片', + }; + + const seen = new Set(); + for (const r of results) { + const title = (r.title || r.note || '') as string; + for (const [key, val] of Object.entries(genreKeywords)) { + if (title.includes(key) && !seen.has(val)) { + seen.add(val); + tags.push(val); + } + } + } + + return tags; +} + +/** + * Query DB for previously saved resources that match the keyword. + * Returns formatted results for immediate streaming before external API call. + */ +function getSavedResources(keyword: string): any[] { + try { + const db = getDb(); + const rows = db.prepare(` + SELECT source_url, source_title, target_cloud, share_url, created_at + FROM save_records + WHERE status = 'success' + AND (source_title LIKE ? OR source_url LIKE ?) + ORDER BY created_at DESC + LIMIT 20 + `).all(`%${keyword}%`, `%${keyword}%`) as any[]; + + return rows.map((row: any, idx: number) => ({ + id: `saved_${idx}`, + title: row.source_title || row.source_url || '', + description: '', + share_url: row.share_url || row.source_url || '', + cloud_type: detectCloudType(row.share_url || row.source_url || ''), + file_size: '', + update_time: row.created_at || '', + source: 'local', + file_id: '', + cover: '', + password: '', + })); + } catch (err) { + console.error('[SavedResources] DB query error:', err); + return []; + } +} + +export default router; \ No newline at end of file diff --git a/packages/backend/src/routes/upload.routes.ts b/packages/backend/src/routes/upload.routes.ts new file mode 100644 index 0000000..16e055f --- /dev/null +++ b/packages/backend/src/routes/upload.routes.ts @@ -0,0 +1,125 @@ +import { Router, Request, Response } from 'express'; +import multer from 'multer'; +import sharp from 'sharp'; +import path from 'path'; +import fs from 'fs'; +import { authMiddleware } from '../admin/auth.service'; +import { updateSystemConfig } from '../admin/system-config.service'; + +const router = Router(); + +// ============ Upload ============ + +/** + * POST /api/admin/upload-fallback-image + * Upload a fallback cover image for search results without covers. + * Recommended: 320×180 JPEG/PNG (16:9), max 2MB. + */ +const uploadDir = path.resolve('/app/uploads/fallback'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const fallbackStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, _file, cb) => { + const ext = '.jpg'; + cb(null, `fallback_cover_tmp${ext}`); + }, +}); + +const upload = multer({ + storage: fallbackStorage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('仅支持图片文件(JPEG/PNG)')); + } + }, +}); + +router.post('/admin/upload-fallback-image', authMiddleware, upload.single('image'), async (req: Request, res: Response) => { + try { + if (!req.file) { + res.status(400).json({ error: '请选择要上传的图片' }); + return; + } + // 压缩:最大宽度320px,JPEG quality 80 + const outPath = path.resolve(uploadDir, 'fallback_cover.jpg'); + await sharp(req.file.path) + .resize(320, undefined, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 80 }) + .toFile(outPath); + // 删除原始上传文件(如果路径不同) + if (req.file.path !== outPath) { + fs.unlink(req.file.path, () => {}); + } + const url = `/api/uploads/fallback/fallback_cover.jpg`; + updateSystemConfig('search_fallback_image', url); + const stat = fs.statSync(outPath); + res.json({ success: true, url, message: `✅ 兜底图已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` }); + } catch (err: any) { + res.status(500).json({ error: err.message || '上传失败' }); + } +}); + +/** + * POST /api/admin/upload-logo + * Upload a site logo image displayed on search page (home link) and homepage. + * Recommended: 320×60 or similar wide/banner ratio, JPEG/PNG/WebP, max 2MB. + */ +const logoUploadDir = path.resolve('/app/uploads/logo'); +if (!fs.existsSync(logoUploadDir)) { + fs.mkdirSync(logoUploadDir, { recursive: true }); +} + +const logoStorage = multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, logoUploadDir), + filename: (_req, _file, cb) => { + cb(null, `site_logo_tmp.png`); + }, +}); + +const logoUpload = multer({ + storage: logoStorage, + limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('仅支持图片文件(JPEG/PNG/WebP)')); + } + }, +}); + +router.post('/admin/upload-logo', authMiddleware, logoUpload.single('image'), async (req: Request, res: Response) => { + try { + if (!req.file) { + res.status(400).json({ error: '请选择要上传的图片' }); + return; + } + // 压缩:最大宽度640px,PNG格式 + const outPath = path.resolve(logoUploadDir, 'site_logo.png'); + await sharp(req.file.path) + .resize(640, undefined, { fit: 'inside', withoutEnlargement: true }) + .png({ compressionLevel: 9 }) + .toFile(outPath); + if (req.file.path !== outPath) { + fs.unlink(req.file.path, () => {}); + } + const url = `/api/uploads/logo/site_logo.png`; + updateSystemConfig('site_logo', url); + const stat = fs.statSync(outPath); + res.json({ success: true, url, message: `✅ 站点图标已压缩上传 (${(stat.size / 1024).toFixed(1)}KB)` }); + } catch (err: any) { + res.status(500).json({ error: err.message || '上传失败' }); + } +}); + +import { startQrLogin, getQrLoginStatus, cancelQrLogin } from '../cloud/qr-login.service'; + +// ===== 夸克扫码登录 (不需要 auth,用户未登录时也需要能用) ===== + +export default router; \ No newline at end of file diff --git a/packages/backend/src/search/rankings.service.ts b/packages/backend/src/search/rankings.service.ts new file mode 100755 index 0000000..7e878de --- /dev/null +++ b/packages/backend/src/search/rankings.service.ts @@ -0,0 +1,351 @@ +// Native fetch available in Node 20+ +import { getDb } from '../database/database'; +import { getTimezone, formatLocalDateTime } from '../utils/time'; + +export interface RankingItem { + keyword: string; + searchCount: number; + updatedAt: string; + rating?: number; +} + +export interface CategorizedRanking { + category: string; + label: string; + hot: RankingItem[]; + newest: RankingItem[]; +} + +export interface CategorizedResponse { + fetchedAt: string; + categories: CategorizedRanking[]; +} + +// ===== Bilibili PGC 排行榜配置 ===== +interface BiliPgcDef { + category: string; + label: string; + season_type: number; // 1=番剧, 2=电影, 3=纪录片, 4=国创, 5=电视剧, 7=综艺 +} + +const BILI_PGC_CATEGORIES: BiliPgcDef[] = [ + // 国创:凡人修仙传、灵笼、斗破苍穹等官方国产动画 + { category: 'donghua', label: '国产动漫', season_type: 4 }, + // 番剧:日漫等全球动画 + { category: 'global_anime', label: '热门动漫', season_type: 1 }, +]; + +// ===== 百度热搜榜配置 ===== +interface BaiduBoardDef { + category: string; + label: string; + tab: string; // movie=电影热搜, teleplay=电视剧热搜 +} + +const BAIDU_BOARDS: BaiduBoardDef[] = [ + // 百度电影热搜:实时反映国内电影热度 + { category: 'movie', label: '国内电影', tab: 'movie' }, + // 百度电视剧热搜:国内剧集热度 + { category: 'tv', label: '热门剧集', tab: 'teleplay' }, +]; + +// ===== TMDB 分类配置(保留欧美和冷门内容)===== +interface TmdbCategoryDef { + category: string; + label: string; + hotUrl: string; + newestUrl: string; +} + +const TMDB_CATEGORIES: TmdbCategoryDef[] = [ + { + category: 'western_movie', label: '欧美电影', + hotUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10', + newestUrl: 'https://api.themoviedb.org/3/discover/movie?with_origin_country=US&sort_by=release_date.desc&vote_count.gte=1', + }, + { + category: 'western_tv', label: '欧美剧集', + hotUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=vote_average.desc&vote_count.gte=10', + newestUrl: 'https://api.themoviedb.org/3/discover/tv?with_origin_country=US&sort_by=first_air_date.desc&vote_count.gte=10', + }, + { + category: 'niche', label: '冷门佳片', + hotUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=vote_average.desc&vote_count.gte=10&vote_count.lte=500', + newestUrl: 'https://api.themoviedb.org/3/discover/movie?sort_by=release_date.desc&vote_count.gte=1&vote_count.lte=500', + }, +]; + +// ===== 显示顺序 ===== +const CATEGORY_ORDER: Record = { + donghua: 1, + movie: 2, + tv: 3, + global_anime: 4, + western_movie: 5, + western_tv: 6, + niche: 7, + hotsite: 8, +}; + +// ===== 12小时缓存 ===== +let cache: { data: CategorizedResponse; time: number } | null = null; +const CACHE_TTL = 12 * 60 * 60 * 1000; + +function isCacheValid(): boolean { + return cache !== null && (Date.now() - cache.time) < CACHE_TTL; +} + +// ===== Bilibili PGC API ===== + +/** + * 抓取 Bilibili PGC 排行榜(番剧/国创) + */ +async function fetchFromBiliPgc(season_type: number): Promise { + try { + const url = `https://api.bilibili.com/pgc/web/rank/list?season_type=${season_type}&day=7`; + const resp = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://www.bilibili.com/', + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'zh-CN,zh;q=0.9', + }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + console.error(`[BiliPGC] HTTP ${resp.status} for season_type=${season_type}`); + return []; + } + const json = await resp.json() as any; + if (json.code !== 0 || !json.result?.list) { + console.error(`[BiliPGC] API error code=${json.code} for season_type=${season_type}`); + return []; + } + return json.result.list.slice(0, 20).map((item: any) => { + const stat = item.stat || {}; + const viewCount = stat.view || 0; + const followCount = stat.follow || 0; + const searchCount = viewCount > 0 ? viewCount : followCount; + + let rating = 0; + if (item.rating) { + const m = String(item.rating).match(/([\d.]+)/); + if (m) rating = parseFloat(m[1]); + } + + return { + keyword: item.title || '', + searchCount, + updatedAt: item.new_ep?.index_show || item.new_ep?.cover || '', + rating, + }; + }); + } catch (err) { + console.error(`[BiliPGC] Fetch error for season_type=${season_type}:`, (err as Error).message); + return []; + } +} + +// ===== 百度热搜榜 API ===== + +/** + * 抓取百度热搜榜 + * tab: movie=电影, teleplay=电视剧 + */ +async function fetchFromBaidu(tab: string): Promise { + try { + const url = `https://top.baidu.com/api/board?tab=${tab}`; + const resp = await fetch(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://top.baidu.com/board', + }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + console.error(`[Baidu] HTTP ${resp.status} for tab=${tab}`); + return []; + } + const json = await resp.json() as any; + if (!json.success || !json.data?.cards) { + console.error(`[Baidu] API error for tab=${tab}`); + return []; + } + + const results: RankingItem[] = []; + for (const card of json.data.cards) { + for (const item of (card.content || [])) { + results.push({ + keyword: item.word || '', + // hotScore can be like "96438", parse as number + searchCount: parseInt(item.hotScore || '0', 10) || 0, + updatedAt: item.desc || '', + rating: 0, + }); + } + } + + return results.slice(0, 20); + } catch (err) { + console.error(`[Baidu] Fetch error for tab=${tab}:`, (err as Error).message); + return []; + } +} + +// ===== TMDB ===== + +function getTmdbToken(): string { + const db = getDb(); + return (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('tmdb_api_token') as any)?.value || ''; +} + +function tmdbResultToRanking(item: any): RankingItem { + const title = item.title || item.name || ''; + const date = item.release_date || item.first_air_date || ''; + const rating = item.vote_average ? Math.round(item.vote_average * 10) / 10 : 0; + return { + keyword: title, + searchCount: item.vote_count || 0, + updatedAt: date, + rating, + }; +} + +async function tmdbFetch(url: string, token: string): Promise { + const fullUrl = `${url}${url.includes('?') ? '&' : '?'}language=zh-CN`; + try { + const resp = await fetch(fullUrl, { + headers: { 'Authorization': `Bearer ${token}` }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) { + console.error(`[TMDB] HTTP ${resp.status} for ${url}`); + return []; + } + const data = await resp.json() as any; + return (data.results || []).slice(0, 20); + } catch (err) { + console.error(`[TMDB] Fetch error for ${url}:`, err); + return []; + } +} + +// ===== 主流程 ===== + +async function fetchRankings(): Promise { + const fetchedAt = formatLocalDateTime(); + + // 1. 并行抓取 Bilibili PGC 数据(国漫、番剧) + const biliPromises = BILI_PGC_CATEGORIES.map(async (cat) => { + const results = await fetchFromBiliPgc(cat.season_type); + const mid = Math.ceil(results.length / 2); + return { + category: cat.category, + label: cat.label, + hot: results.slice(0, mid), + newest: results.slice(mid), + }; + }); + + // 2. 并行抓取百度热搜数据(电影、电视剧) + // 百度只有热榜没有最新榜,全部放 hot + const baiduPromises = BAIDU_BOARDS.map(async (board) => { + const results = await fetchFromBaidu(board.tab); + return { + category: board.category, + label: board.label, + hot: results, + newest: [], + }; + }); + + // 3. 并行抓取 TMDB 数据(欧美观影、剧集、冷门) + const token = getTmdbToken(); + let tmdbResults: CategorizedRanking[] = []; + if (token) { + const tmdbPromises = TMDB_CATEGORIES.map(async (cat) => { + const [hotResults, newestResults] = await Promise.all([ + tmdbFetch(cat.hotUrl, token), + tmdbFetch(cat.newestUrl, token), + ]); + return { + category: cat.category, + label: cat.label, + hot: hotResults.map(tmdbResultToRanking), + newest: newestResults.map(tmdbResultToRanking), + }; + }); + tmdbResults = await Promise.all(tmdbPromises); + } + + // 4. 本站热搜 + const db = getDb(); + const rows = db.prepare( + 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + ).all() as RankingItem[]; + const newestRows = db.prepare( + 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY updated_at DESC LIMIT 20' + ).all() as RankingItem[]; + + const hotsiteCategory: CategorizedRanking = { + category: 'hotsite', + label: '本站热搜', + hot: rows, + newest: newestRows, + }; + + // 5. 合并所有结果 + const [biliResults, baiduResults] = await Promise.all([ + Promise.all(biliPromises), + Promise.all(baiduPromises), + ]); + const allCategories = [...biliResults, ...baiduResults, ...tmdbResults, hotsiteCategory]; + + // 按 CATEGORY_ORDER 排序 + allCategories.sort((a, b) => (CATEGORY_ORDER[a.category] || 99) - (CATEGORY_ORDER[b.category] || 99)); + + return { fetchedAt, categories: allCategories }; +} + +export async function getCategorizedRankings(): Promise { + if (isCacheValid()) { + return cache!.data; + } + + try { + const data = await fetchRankings(); + cache = { data, time: Date.now() }; + return data; + } catch (err) { + console.error('[Rankings] Fetch error:', err); + if (cache) return cache.data; + const db = getDb(); + const rows = db.prepare( + 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + ).all() as RankingItem[]; + return { + fetchedAt: formatLocalDateTime(), + categories: [{ + category: 'hotsite', label: '本站热搜', + hot: rows, + newest: [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 20), + }], + }; + } +} + +export async function getRankings(): Promise { + const db = getDb(); + const rows = db.prepare( + 'SELECT keyword, search_count as searchCount, updated_at as updatedAt FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + ).all() as RankingItem[]; + return rows; +} + +export async function getHotKeywords(): Promise { + const db = getDb(); + const rows = db.prepare( + 'SELECT keyword FROM hot_keywords ORDER BY search_count DESC LIMIT 20' + ).all() as { keyword: string }[]; + return rows.map(r => r.keyword); +} diff --git a/packages/backend/src/search/search-optimizer.ts b/packages/backend/src/search/search-optimizer.ts new file mode 100755 index 0000000..ff9afb3 --- /dev/null +++ b/packages/backend/src/search/search-optimizer.ts @@ -0,0 +1,125 @@ +/** + * Search Results Optimizer + * + * For each cloud type, keep only the top N most relevant results. + * Order groups by priority: real cloud storage > other providers > magnet/others. + * + * Goal: give users a manageable, high-quality result set instead of overwhelming them + * with hundreds of results dominated by magnet links. + */ + +import { detectCloudType } from '../config/cloud-labels'; + +/** Minimal result shape the optimizer needs */ +interface OptimizableResult { + title?: string; + url?: string; + source?: string; + score?: number; + [key: string]: any; +} + +/** Priority tiers for result ordering */ +const CLOUD_PRIORITY: Record = { + // Tier 1: Major cloud storage (most useful for save-to-cloud feature) + baidu: 10, + quark: 10, + aliyun: 10, + // Tier 2: Other cloud storage + '115': 20, + tianyi: 20, + '123pan': 20, + uc: 20, + xunlei: 20, + pikpak: 20, + // Tier 3: Mobile/app links (not very useful) + mobile: 50, + // Tier 4: Direct links (lowest utility for cloud saving) + magnet: 100, + ed2k: 100, + others: 100, +}; + +const DEFAULT_PRIORITY = 50; + +/** Get cloud type for a result, with an extra check for tracker URLs */ +function getCloudType(result: OptimizableResult): string { + const url = result.url; + // Check for tracker/private-site URLs not covered by shared detection + if (url && /mteam|hdarea|hdsky/i.test(url)) return 'others'; + return detectCloudType(url); +} + +function getPriority(cloudType: string): number { + return CLOUD_PRIORITY[cloudType] ?? DEFAULT_PRIORITY; +} + +export interface OptimizationResult { + results: OptimizableResult[]; + /** Per-type stats for display */ + perType: Array<{ type: string; count: number; total: number }>; + /** How many items were kept vs filtered */ + keptCount: number; + filteredCount: number; +} + +/** + * Optimize search results: + * 1. Group by cloud type + * 2. Sort by score descending within each group + * 3. Keep only top `maxPerType` results per type + * 4. Order groups by priority (cloud storage first) + */ +export function optimizeSearchResults( + items: OptimizableResult[], + maxPerType: number = 20 +): OptimizationResult { + // Step 1: Group by cloud type + const grouped: Record = {}; + const typeTotals: Record = {}; + + for (const item of items) { + const ct = getCloudType(item); + if (!grouped[ct]) { + grouped[ct] = []; + } + grouped[ct].push(item); + typeTotals[ct] = (typeTotals[ct] || 0) + 1; + } + + // Step 2 & 3: Sort each group by score desc, take top N + const kept: OptimizableResult[] = []; + const perType: Array<{ type: string; count: number; total: number }> = []; + + for (const [ct, groupItems] of Object.entries(grouped)) { + // Sort by score descending (higher score = more relevant) + groupItems.sort((a, b) => (b.score || 0) - (a.score || 0)); + + const top = groupItems.slice(0, maxPerType); + kept.push(...top); + + perType.push({ + type: ct, + count: top.length, + total: typeTotals[ct], + }); + } + + // Step 4: Sort kept results by cloud priority, then by score within same priority + kept.sort((a, b) => { + const pa = getPriority(getCloudType(a)); + const pb = getPriority(getCloudType(b)); + if (pa !== pb) return pa - pb; + return (b.score || 0) - (a.score || 0); + }); + + const keptCount = kept.length; + const filteredCount = items.length - keptCount; + + return { + results: kept, + perType, + keptCount, + filteredCount, + }; +} diff --git a/packages/backend/src/search/search.service.ts b/packages/backend/src/search/search.service.ts new file mode 100755 index 0000000..5f2acd1 --- /dev/null +++ b/packages/backend/src/search/search.service.ts @@ -0,0 +1,348 @@ +// Native fetch available in Node 20+ +import config from '../config'; +import { getDb } from '../database/database'; +import { localTimestamp } from '../utils/time'; +import { proxiedFetch } from '../utils/proxy-agent'; + +export interface SearchResult { + title: string; + url: string; + content: string; + score?: number; + source?: string; + password?: string; + datetime?: string; + responseTimeMs?: number; +} + +export interface SearchResponse { + results: SearchResult[]; + total: number; + page: number; + pageSize: number; +} + +export interface ApiSearchSource { + name: string; + url: string; + method?: string; // GET | POST (default POST) + headers?: Record; + body?: string; // JSON body template, supports {keyword} {page} + resultPath: string; // dot-notation path to results array (e.g. "data.list") + fieldMap: { // maps SearchResult fields to JSON response fields + title?: string; + url?: string; + content?: string; + password?: string; + datetime?: string; + }; + timeout?: number; // per-source timeout (ms), default 10000 +} + +/** Simple dot/bracket notation JSON path accessor. */ +function jsonPathGet(obj: any, path: string): any { + if (!obj || !path) return undefined; + const parts = path + .replace(/\[(\d+)\]/g, '.$1') // items[0] → items.0 + .split('.') + .filter(Boolean); + let current = obj; + for (const part of parts) { + if (current == null) return undefined; + current = current[part]; + } + return current; +} + +/** Parse configured API search sources from system config. */ +function getApiSearchSources(): ApiSearchSource[] { + try { + const db = getDb(); + const raw = (db.prepare("SELECT value FROM system_configs WHERE key = 'api_search_sources'").get() as any)?.value; + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((s: any) => s.url && s.resultPath); + } catch { + return []; + } +} + +/** Query a single API search source and return results with timing. */ +async function queryApiSource( + source: ApiSearchSource, + keyword: string, + page: number, + proxyUrl?: string, +): Promise<{ source: string; results: SearchResult[]; responseTimeMs: number; error?: string }> { + const startTime = Date.now(); + const timeout = source.timeout || 10000; + const method = (source.method || 'POST').toUpperCase(); + + try { + let url = source.url; + const headers: Record = { ...source.headers }; + + let body: string | undefined; + if (source.body) { + body = source.body + .replace(/\{keyword\}/g, encodeURIComponent(keyword)) + .replace(/\{page\}/g, String(page)); + } + + // For GET requests, append query params; for POST, use body + const fetchOptions: RequestInit = { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + signal: AbortSignal.timeout(timeout), + }; + + // For GET requests, append query params; for POST, use body + if (method === 'GET') { + // Parse body as JSON and convert to query string + if (body) { + try { + const params = JSON.parse(body); + const qs = new URLSearchParams(params).toString(); + url += (url.includes('?') ? '&' : '?') + qs; + } catch { + // If body is not JSON, append as raw query + url += (url.includes('?') ? '&' : '?') + body; + } + } + } else { + (fetchOptions as any).body = body || JSON.stringify({ keyword, page }); + } + + const response = await proxiedFetch(url, fetchOptions, proxyUrl); + const responseTimeMs = Date.now() - startTime; + + if (!response.ok) { + return { source: source.name, results: [], responseTimeMs, error: `HTTP ${response.status}` }; + } + + const data = await response.json(); + const resultTimeMs = Date.now() - startTime; + + // Extract results array using JSONPath + const items = jsonPathGet(data, source.resultPath); + if (!Array.isArray(items)) { + return { source: source.name, results: [], responseTimeMs: resultTimeMs, error: 'resultPath not found or not an array' }; + } + + // Map fields + const fm = source.fieldMap || {}; + const results: SearchResult[] = items.map((item: any) => ({ + title: (fm.title ? item[fm.title] : item.title) || item.name || '', + url: (fm.url ? item[fm.url] : item.url) || item.link || '', + content: (fm.content ? item[fm.content] : item.content) || item.snippet || '', + password: (fm.password ? item[fm.password] : item.password) || '', + datetime: (fm.datetime ? item[fm.datetime] : item.datetime) || item.date || '', + source: source.name, + responseTimeMs: resultTimeMs, + })); + + return { source: source.name, results, responseTimeMs: resultTimeMs }; + } catch (err: any) { + const responseTimeMs = Date.now() - startTime; + return { source: source.name, results: [], responseTimeMs, error: err.message }; + } +} + +/** Query all configured API search sources in parallel. */ +async function searchApiSources(keyword: string, page: number, proxyUrl?: string): Promise<{ + results: SearchResult[]; + sourceStats: { name: string; count: number; responseTimeMs: number; error?: string }[]; +}> { + const sources = getApiSearchSources(); + if (sources.length === 0) return { results: [], sourceStats: [] }; + + const promises = sources.map(s => queryApiSource(s, keyword, page, proxyUrl)); + const allResults = await Promise.all(promises); + + const sourceStats = allResults.map(r => ({ + name: r.source, + count: r.results.length, + responseTimeMs: r.responseTimeMs, + error: r.error, + })); + + // Merge all results, tag with source name, sort by response time (fastest first) + const results = allResults + .flatMap(r => r.results) + .sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999)); + + return { results, sourceStats }; +} + +export async function search(keyword: string, page: number = 1, ip?: string): Promise { + const db = getDb(); + const pansouUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('pansou_url') as any)?.value || config.pansouUrl; + const proxyEnabled = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_enabled') as any)?.value === 'true'; + const proxyUrl = (db.prepare('SELECT value FROM system_configs WHERE key = ?').get('search_proxy_url') as any)?.value || ''; + const effectiveProxy = proxyEnabled ? proxyUrl : undefined; + + // ── Run PanSou and API sources in parallel ── + const pansouPromise = (async () => { + const url = `${pansouUrl}/api/search`; + const fetchOptions: RequestInit = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kw: keyword, page }), + signal: AbortSignal.timeout(10000), + }; + const pansouStart = Date.now(); + const response = await proxiedFetch(url, fetchOptions, effectiveProxy); + if (!response.ok) throw new Error(`PanSou API error: ${response.status}`); + const data = await response.json() as any; + return { data, responseTimeMs: Date.now() - pansouStart }; + })(); + + const apiSourcesPromise = searchApiSources(keyword, page, effectiveProxy); + + const [pansouResult, apiSourcesResult] = await Promise.all([pansouPromise, apiSourcesPromise]); + + const { data, responseTimeMs: pansouTime } = pansouResult; + + // ── Parse PanSou results ── + let items: any[] = []; + let total = 0; + + if (data.data?.merged_by_type) { + for (const [cloudType, cloudItems] of Object.entries(data.data.merged_by_type)) { + if (Array.isArray(cloudItems)) { + items.push(...cloudItems.map((item: any) => ({ + ...item, + _cloud_type: cloudType, + }))); + } + } + total = data.data.total || items.length; + } else if (Array.isArray(data.data)) { + items = data.data; + total = data.total || items.length; + } else if (Array.isArray(data.results)) { + items = data.results; + total = data.total || items.length; + } + + const pansouResults: SearchResult[] = items.map((item: any) => ({ + title: item.note || item.title || '', + url: item.url || item.link || '', + content: item.content || item.snippet || item.note || '', + score: item.score || 0, + source: item.source || item._cloud_type || 'pansou', + password: item.password || '', + datetime: item.datetime || '', + responseTimeMs: pansouTime, + images: item.images || [], + })); + + // ── Merge PanSou + API sources, sort by response time (fastest first) ── + const allResults = [...apiSourcesResult.results, ...pansouResults] + .sort((a, b) => (a.responseTimeMs || 99999) - (b.responseTimeMs || 99999)); + + // Deduplicate by URL within merged results + const seenUrls = new Set(); + const results: SearchResult[] = []; + for (const r of allResults) { + if (r.url && !seenUrls.has(r.url)) { + seenUrls.add(r.url); + results.push(r); + } else if (!r.url) { + results.push(r); // keep results without URLs (unlikely but safe) + } + } + + total = results.length; + + // Sort by datetime descending as secondary sort (preserve response-time groups) + results.sort((a: any, b: any) => { + const ta = a.datetime || ''; + const tb = b.datetime || ''; + if (!ta && !tb) return 0; + if (!ta) return 1; + if (!tb) return -1; + return tb.localeCompare(ta); + }); + + // Record search statistics + recordSearchStats(keyword, results.length, ip); + + // Update hot keywords + updateHotKeywords(keyword); + + return { + results, + total, + page, + pageSize: data.pageSize || 10, + }; +} + +/** + * Apply title filter rules to clean up search result titles. + * Rules format (one per line): + * # comment lines are ignored (hash must be followed by space) + * /pattern/flags → regex: matched content is deleted from title + * plain text → literal text: exact text is deleted from title wherever it appears + */ +export function applyTitleFilter(title: string, rules: string): string { + if (!title || !rules) return title; + const lines = rules.split('\n'); + let result = title; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('# ')) continue; + try { + if (line.startsWith('/') && line.lastIndexOf('/') > 0) { + const lastSlashIdx = line.lastIndexOf('/'); + const pattern = line.substring(1, lastSlashIdx); + const flags = line.substring(lastSlashIdx + 1); + const anchored = pattern.startsWith('^') ? pattern : '^' + pattern; + const re = new RegExp(anchored, flags); + const match = re.exec(result); + if (match && match.index === 0) { + result = result.slice(match[0].length); + } + } else { + if (result.startsWith(line)) { + result = result.slice(line.length); + } + } + } catch { + continue; + } + } + return result.trim(); +} + +function recordSearchStats(keyword: string, resultCount: number, ip?: string): void { + try { + const db = getDb(); + db.prepare( + 'INSERT INTO search_stats (keyword, intent, result_count, ip_address, created_at) VALUES (?, ?, ?, ?, ?)' + ).run(keyword, 'SEARCH', resultCount, ip || '', localTimestamp()); + } catch (err) { + console.error('[Search] Failed to record stats:', err); + } +} + +function updateHotKeywords(keyword: string): void { + try { + const db = getDb(); + const existing = db.prepare('SELECT id FROM hot_keywords WHERE keyword = ?').get(keyword) as any; + + if (existing) { + db.prepare( + "UPDATE hot_keywords SET search_count = search_count + 1, updated_at = ? WHERE keyword = ?" + ).run(localTimestamp(), keyword); + } else { + db.prepare( + "INSERT INTO hot_keywords (keyword, search_count, updated_at) VALUES (?, 1, ?)" + ).run(keyword, localTimestamp()); + } + } catch (err) { + console.error('[Search] Failed to update hot keywords:', err); + } +} diff --git a/packages/backend/src/utils/crypto.ts b/packages/backend/src/utils/crypto.ts new file mode 100644 index 0000000..3034639 --- /dev/null +++ b/packages/backend/src/utils/crypto.ts @@ -0,0 +1,111 @@ +/** + * AES-256-GCM 加解密工具 + * + * 用于保护数据库中存储的网盘 Cookie。 + * 加密密钥从环境变量 COOKIE_ENCRYPTION_KEY 读取, + * 未设置时自动生成随机密钥(仅当前进程有效,重启后旧数据不可解密)。 + * + * 生产环境必须设置 COOKIE_ENCRYPTION_KEY! + */ +import * as crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit nonce for GCM +const TAG_LENGTH = 16; // 128-bit auth tag +const KEY_LENGTH = 32; // 256-bit key + +let ENCRYPTION_KEY: Buffer | null = null; + +function getKey(): Buffer { + if (ENCRYPTION_KEY) return ENCRYPTION_KEY; + + const envKey = process.env.COOKIE_ENCRYPTION_KEY; + if (envKey && envKey.length >= 32) { + // Use SHA-256 to derive a consistent 32-byte key from any length input + ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); + console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)'); + } else if (envKey) { + // Short key: still use SHA-256 + ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); + console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)'); + } else { + // Default stable key (not ephemeral) — data survives container restart + ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest(); + console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)'); + } + return ENCRYPTION_KEY; +} + +/** + * Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag). + */ +export function encrypt(plaintext: string): string { + if (!plaintext) return ''; + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + // Format: iv (12) + tag (16) + ciphertext + const combined = Buffer.concat([iv, tag, encrypted]); + return combined.toString('base64'); +} + +/** + * Decrypt base64-encoded ciphertext. Returns original plaintext. + * Returns empty string if decryption fails (corrupted data or wrong key). + */ +export function decrypt(encoded: string): string { + if (!encoded) return ''; + try { + const key = getKey(); + const combined = Buffer.from(encoded, 'base64'); + + if (combined.length < IV_LENGTH + TAG_LENGTH + 1) { + console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)'); + // Legacy data: stored as plaintext before encryption was added + return encoded; + } + + const iv = combined.subarray(0, IV_LENGTH); + const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } catch (err: any) { + // If it looks like base64 but decryption fails, it might be legacy plaintext + // stored before encryption was enabled. Try returning as-is. + if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) { + console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is'); + return encoded; + } + console.error('[Crypto] Decryption error:', err.message); + return ''; + } +} + +/** + * Check if a string appears to be encrypted (base64 with IV+tag prefix). + * Used for migration: re-encrypt legacy plaintext cookies. + */ +export function isEncrypted(value: string): boolean { + if (!value) return false; + try { + const combined = Buffer.from(value, 'base64'); + return combined.length > IV_LENGTH + TAG_LENGTH; + } catch { + return false; + } +} diff --git a/packages/backend/src/utils/logger.ts b/packages/backend/src/utils/logger.ts new file mode 100644 index 0000000..59ba5b0 --- /dev/null +++ b/packages/backend/src/utils/logger.ts @@ -0,0 +1,73 @@ +/** + * 结构化日志工具 + * + * 统一日志格式,支持请求追踪。 + */ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +interface LogEntry { + level: LogLevel; + message: string; + timestamp: string; + module?: string; + duration?: number; + error?: string; +} + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +let currentLevel: LogLevel = + (process.env.LOG_LEVEL as LogLevel) || + (process.env.NODE_ENV === 'production' ? 'info' : 'debug'); + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel]; +} + +function formatLog(entry: LogEntry): string { + const parts = [ + `[${entry.timestamp}]`, + `[${entry.level.toUpperCase()}]`, + ]; + if (entry.module) parts.push(`[${entry.module}]`); + parts.push(entry.message); + if (entry.duration !== undefined) parts.push(`(${entry.duration}ms)`); + if (entry.error) parts.push(`\n ${entry.error}`); + return parts.join(' '); +} + +function log(level: LogLevel, message: string, module?: string, extra?: Record): void { + if (!shouldLog(level)) return; + + const entry: LogEntry = { + level, + message, + timestamp: new Date().toISOString(), + module, + ...extra, + }; + + const formatted = formatLog(entry); + switch (level) { + case 'error': console.error(formatted); break; + case 'warn': console.warn(formatted); break; + default: console.log(formatted); break; + } +} + +export const logger = { + debug: (msg: string, module?: string) => log('debug', msg, module), + info: (msg: string, module?: string) => log('info', msg, module), + warn: (msg: string, module?: string) => log('warn', msg, module), + error: (msg: string, module?: string, err?: Error) => + log('error', msg, module, err ? { error: err.stack || err.message } : undefined), + + /** Log with duration (for performance tracking) */ + perf: (msg: string, durationMs: number, module?: string) => + log('info', msg, module, { duration: durationMs }), +}; diff --git a/packages/backend/src/utils/proxy-agent.ts b/packages/backend/src/utils/proxy-agent.ts new file mode 100644 index 0000000..7f0656f --- /dev/null +++ b/packages/backend/src/utils/proxy-agent.ts @@ -0,0 +1,143 @@ +/** + * 统一代理工具 — 支持 HTTP/HTTPS/SOCKS5/SOCKS5h 协议 + * + * Node 20+ 原生 fetch() 使用 undici Dispatcher,但 socks-proxy-agent 不实现此接口。 + * 解决方案:使用 http.Agent 接口 + http/https.request()。 + */ + +let HttpsProxyAgent: any; +let SocksProxyAgent: any; + +try { + HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent; +} catch { + try { HttpsProxyAgent = require('https-proxy-agent'); } catch {} +} + +try { + SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent; +} catch { + try { SocksProxyAgent = require('socks-proxy-agent'); } catch {} +} + +/** Create an http.Agent for the given proxy URL (works with https.request) */ +function createProxyAgent(proxyUrl: string): any | null { + if (!proxyUrl || typeof proxyUrl !== 'string') return null; + const trimmed = proxyUrl.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + + try { + if (lower.startsWith('socks5://') || lower.startsWith('socks5h://')) { + if (!SocksProxyAgent) { + console.warn('[Proxy] socks-proxy-agent not installed'); + return null; + } + return new SocksProxyAgent(trimmed); + } + if (lower.startsWith('http://') || lower.startsWith('https://')) { + if (!HttpsProxyAgent) { + console.warn('[Proxy] No HTTP proxy agent available'); + return null; + } + return new HttpsProxyAgent(trimmed); + } + // Unknown scheme — try as HTTP proxy + if (HttpsProxyAgent) return new HttpsProxyAgent(trimmed); + return null; + } catch (err: any) { + console.error(`[Proxy] Failed to create proxy agent: ${err.message}`); + return null; + } +} + +/** + * Fetch with proxy support. + * Uses native fetch() when no proxy, or http/https.request() with agent when proxy is set. + */ +export async function proxiedFetch( + url: string, + init?: RequestInit, + proxyUrl?: string, +): Promise { + if (!proxyUrl) return fetch(url, init); + + const agent = createProxyAgent(proxyUrl); + if (!agent) return fetch(url, init); + + const parsedUrl = new URL(url); + const mod = parsedUrl.protocol === 'https:' ? require('https') : require('http'); + + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (init?.headers) { + const h = init.headers as any; + if (h instanceof Headers) { + h.forEach((v, k) => { headers[k] = v; }); + } else if (typeof h === 'object') { + Object.assign(headers, h); + } + } + + const options: any = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), + path: parsedUrl.pathname + parsedUrl.search, + method: init?.method || 'GET', + headers, + agent, + }; + + const req = mod.request(options, (res: any) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const body = Buffer.concat(chunks); + resolve(new Response(body, { + status: res.statusCode || 502, + statusText: res.statusMessage || '', + headers: new Headers(res.headers || {}), + })); + }); + }); + + req.on('error', reject); + + if (init?.signal) { + init.signal.addEventListener('abort', () => req.destroy()); + } + + if (init?.body) { + req.write( + typeof init.body === 'string' ? init.body : + init.body instanceof Buffer ? init.body : + init.body instanceof ArrayBuffer ? Buffer.from(init.body) : + Buffer.from(String(init.body)) + ); + } + req.end(); + }); +} + +export async function testProxyConnection( + proxyUrl: string, + testUrl?: string, +): Promise<{ ok: boolean; latency: number; info: string }> { + const target = testUrl || 'https://www.baidu.com'; + const start = Date.now(); + try { + const res = await proxiedFetch(target, { + signal: AbortSignal.timeout(10000), + }, proxyUrl); + const latency = Date.now() - start; + return { ok: true, latency, info: `连接成功 (${res.status})` }; + } catch (err: any) { + return { ok: false, latency: Date.now() - start, info: `代理连接失败: ${err.message}` }; + } +} + +// Legacy compat — no longer returns dispatcher, kept for type compatibility +export function createProxyDispatcher(proxyUrl: string): { agent?: any } | null { + const agent = createProxyAgent(proxyUrl); + return agent ? { agent } : null; +} diff --git a/packages/backend/src/utils/qr-login.service.ts b/packages/backend/src/utils/qr-login.service.ts new file mode 100755 index 0000000..742a34a --- /dev/null +++ b/packages/backend/src/utils/qr-login.service.ts @@ -0,0 +1,407 @@ +import { chromium, BrowserContext, Page } from 'playwright'; +import jsQR from 'jsqr'; +import { getDb } from '../database/database'; +import { escapeLike } from '../utils/time'; + +interface QrSession { + id: string; + browserContext: BrowserContext; + page: Page; + createdAt: number; + cookieSnapshot: string; + lastPollAt: number; + qrUrl: string; + status: 'pending' | 'scanned' | 'logged_in' | 'expired' | 'error'; + error?: string; +} + +const SESSIONS = new Map(); +const SESSION_TTL = 5 * 60 * 1000; // 5 minutes +const COOKIE_CHECK_INTERVAL = 1500; // 1.5s between cookie checks + +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser'; + +// Clean up old sessions periodically +setInterval(() => { + const now = Date.now(); + for (const [id, session] of SESSIONS.entries()) { + if (now - session.createdAt > SESSION_TTL) { + cleanupSession(id); + } + } +}, 60000); + +function cleanupSession(id: string) { + const session = SESSIONS.get(id); + if (session) { + try { + session.browserContext.close().catch(() => {}); + } catch {} + SESSIONS.delete(id); + } +} + +/** + * Extract QR code URL from the login page canvas using jsQR. + * The actual login QR code is Canvas #0 (anonymous, 177x177), NOT #react-qrcode-logo. + */ +async function extractQrUrl(page: Page): Promise { + // Run inside Playwright's browser context (as a string to avoid Node TS type errors) + const raw = await page.evaluate(`(() => { + const canvases = document.querySelectorAll('canvas'); + var results = []; + for (var i = 0; i < canvases.length; i++) { + try { + var c = canvases[i]; + var ctx = c.getContext('2d'); + if (!ctx) continue; + var imageData = ctx.getImageData(0, 0, c.width, c.height); + results.push({ + index: i, + w: c.width, + h: c.height, + data: Array.from(imageData.data) + }); + } catch(e) {} + } + return results; + })()`) as unknown as { index: number; w: number; h: number; data: number[] }[]; + + if (!raw || raw.length === 0) { + throw new Error('页面没有可用的 canvas'); + } + + // Try to decode each canvas, preferring the one with su.quark.cn URL + let bestUrl = ''; + let bestResult: { index: number; w: number; h: number; data: number[] } | null = null; + + for (const canvas of raw) { + const code = jsQR(new Uint8ClampedArray(canvas.data), canvas.w, canvas.h); + if (code && code.data) { + // If this is the login QR code (has su.quark.cn), use it immediately + if (code.data.includes('su.quark.cn')) { + return code.data; + } + // Otherwise keep it as fallback + if (!bestUrl) { + bestUrl = code.data; + bestResult = canvas; + } + } + } + + if (bestUrl) { + return bestUrl; + } + + throw new Error('无法解析二维码内容'); +} + +/** + * Start a QR code login session. + * Launches headless Chromium, navigates to Quark login page, extracts QR code URL. + */ +export async function startQrLogin(): Promise<{ + sessionId: string; + qrUrl: string; + expiresIn: number; +}> { + // Clean up any existing expired sessions + for (const [id, session] of SESSIONS.entries()) { + if (Date.now() - session.createdAt > SESSION_TTL) { + cleanupSession(id); + } + } + + const browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-first-run', + '--no-zygote', + ], + }); + + const browserContext = await browser.newContext({ + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 }, + locale: 'zh-CN', + }); + + const page = await browserContext.newPage(); + const sessionId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + + try { + // Navigate to Quark login page (now the homepage itself has QR login) + await page.goto('https://pan.quark.cn/', { + waitUntil: 'commit', + timeout: 30000, + }); + + // Wait for the QR code canvas to appear + await page.waitForSelector('canvas', { timeout: 15000 }); + + // Extra wait for the QR code to fully render + await page.waitForTimeout(2000); + + // Extract the QR code URL from the canvas + const qrUrl = await extractQrUrl(page); + + // Take initial cookie snapshot + const cookies = await browserContext.cookies(); + const cookieSnapshot = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + const session: QrSession = { + id: sessionId, + browserContext, + page, + createdAt: Date.now(), + cookieSnapshot, + lastPollAt: Date.now(), + qrUrl, + status: 'pending', + }; + + SESSIONS.set(sessionId, session); + + // Start background polling for login detection + pollLoginStatus(session); + + // Handle page navigation (like redirect after login) + page.on('framenavigated', async (frame) => { + if (frame === page.mainFrame()) { + const url = frame.url(); + if (url === 'about:blank') { + await checkAndCaptureCookies(session); + } + } + }); + + // Handle popups/dialogs + page.on('popup', async (popup) => { + try { + await popup.waitForLoadState('networkidle', { timeout: 10000 }); + await checkAndCaptureCookies(session); + } catch {} + }); + + return { + sessionId, + qrUrl, + expiresIn: SESSION_TTL / 1000, + }; + } catch (err: any) { + // Clean up on failure + try { await browserContext.close(); } catch {} + try { browser.close().catch(() => {}); } catch {} + SESSIONS.delete(sessionId); + throw new Error(`启动扫码登录失败: ${err.message}`); + } +} + +/** + * Poll login status in background. + * Checks cookies every COOKIE_CHECK_INTERVAL ms for new session tokens. + */ +async function pollLoginStatus(session: QrSession) { + const checkInterval = setInterval(async () => { + try { + const now = Date.now(); + + // Check if expired + if (now - session.createdAt > SESSION_TTL) { + clearInterval(checkInterval); + session.status = 'expired'; + cleanupSession(session.id); + return; + } + + session.lastPollAt = now; + + // Check cookies + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + // Check for session cookies indicating login + const hasSessionCookie = cookies.some( + c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') + ); + + if (hasSessionCookie) { + session.cookieSnapshot = cookieStr; + session.status = 'logged_in'; + clearInterval(checkInterval); + return; + } + + // Check URL change as alternative indicator + const url = session.page.url(); + if (!url.includes('login') && !url.includes('qrcode') && url !== 'about:blank' && url !== 'https://pan.quark.cn/' && url.length > 10) { + await checkAndCaptureCookies(session); + } + } catch (err: any) { + // Page might have been closed + clearInterval(checkInterval); + } + }, COOKIE_CHECK_INTERVAL); +} + +/** + * Check cookies after navigation/redirect and capture them if login succeeded. + */ +async function checkAndCaptureCookies(session: QrSession) { + try { + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + const hasSessionCookie = cookies.some( + c => (c.name === '__st' || c.name === 'pus' || c.name === '__pus' || c.name === '__ktd') + ); + + if (hasSessionCookie) { + session.cookieSnapshot = cookieStr; + session.status = 'logged_in'; + } else if (cookies.length > 3) { + const newCookies = cookies.filter( + c => !['ctoken', 'b-user-id', '__wpkreporterwid_'].includes(c.name) + ); + if (newCookies.length > 0) { + session.cookieSnapshot = cookieStr; + try { + const resp = await session.page.evaluate(async () => { + const r = await fetch('https://pan.quark.cn/account/info', { + credentials: 'include', + }); + return await r.text(); + }); + const data = JSON.parse(resp); + if (data?.data?.nickname) { + session.status = 'logged_in'; + } + } catch {} + } + } + } catch {} +} + +/** + * Get the login status for a session. + */ +export async function getQrLoginStatus(sessionId: string): Promise<{ + status: string; + cookie?: string; + nickname?: string; + storage_used?: string; + storage_total?: string; + autoUpdated?: boolean; + updatedConfigId?: number; +}> { + const session = SESSIONS.get(sessionId); + if (!session) { + return { status: 'expired' }; + } + + // Check if expired + if (Date.now() - session.createdAt > SESSION_TTL) { + session.status = 'expired'; + cleanupSession(sessionId); + return { status: 'expired' }; + } + + if (session.status === 'logged_in') { + // Try to get nickname too + let nickname = ''; + try { + const resp = await session.page.evaluate(async () => { + const r = await fetch('https://pan.quark.cn/account/info', { + credentials: 'include', + }); + return await r.text(); + }); + const data = JSON.parse(resp); + nickname = data?.data?.nickname || ''; + } catch {} + + // Fetch capacity info from within the browser context (has full JS signing) + let storageTotal = ''; + let storageUsed = ''; + try { + const capResp = await session.page.evaluate(async () => { + const r = await fetch( + 'https://pan.quark.cn/1/clouddrive/capacity/detail?pr=ucpro&fr=pc', + { credentials: 'include' } + ); + return await r.text(); + }); + const capData = JSON.parse(capResp); + if (capData.status === 200 && capData.data?.capacity_summary) { + const summary = capData.data.capacity_summary; + const total = summary.sum_capacity || 0; + storageTotal = formatBytes(total); + storageUsed = '0 B'; // capacity/detail doesn't return used_size + } + } catch {} + + // Build full cookie string including httpOnly cookies + const cookies = await session.browserContext.cookies(); + const cookieStr = cookies.map(c => `${c.name}=${c.value}`).join('; '); + + // Extract __uid from cookie for duplicate detection + const uidMatch = cookieStr.match(/(? { + cleanupSession(sessionId); +} diff --git a/packages/backend/src/utils/response.ts b/packages/backend/src/utils/response.ts new file mode 100755 index 0000000..d07b223 --- /dev/null +++ b/packages/backend/src/utils/response.ts @@ -0,0 +1,27 @@ +import { Response } from 'express'; + +/** + * Send a successful JSON response. + * Uses the standard format: { error: null, data } + */ +export function sendSuccess(res: Response, data: T, status: number = 200) { + res.status(status).json({ error: null, ...(data as any) }); +} + +/** + * Send an error JSON response. + * Uses the standard format: { error: string } + * All routes should use this for consistent frontend error handling. + */ +export function sendError(res: Response, status: number, message: string) { + res.status(status).json({ error: message }); +} + +/** + * Send a 500 error response from a caught exception. + * Prevents leaking stack traces in production. + */ +export function sendServerError(res: Response, err: unknown, fallbackMessage: string = 'Internal server error') { + const message = err instanceof Error ? err.message : fallbackMessage; + res.status(500).json({ error: message || fallbackMessage }); +} diff --git a/packages/backend/src/utils/time.ts b/packages/backend/src/utils/time.ts new file mode 100755 index 0000000..b16a713 --- /dev/null +++ b/packages/backend/src/utils/time.ts @@ -0,0 +1,116 @@ +import { getDb } from '../database/database'; + +/** + * Get the current timezone from DB config, with fallback. + */ +export function getTimezone(): string { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM system_configs WHERE key = ?').get('timezone') as any; + return row?.value || 'Asia/Shanghai'; + } catch { + return 'Asia/Shanghai'; + } +} + +/** + * Returns current local time as an ISO 8601 string with timezone offset. + * Example: "2026-05-02T14:32:23+08:00" + * This format is reliably parsed by JavaScript Date() in all browsers. + */ +export function localTimestamp(): string { + const tz = getTimezone(); + const now = new Date(); + // Format as local time string with timezone offset + try { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }).formatToParts(now); + const get = (type: string) => parts.find(p => p.type === type)?.value || '00'; + const dateStr = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}`; + // Calculate timezone offset for the configured timezone + // Use getTimezoneOffset difference between UTC and the target timezone + const utcMs = now.getTime(); + const localStr = dateStr.replace('T', ' '); + // Get offset in minutes for the configured timezone + const formatter = new Intl.DateTimeFormat('sv-SE', { + timeZone: tz, + timeZoneName: 'longOffset', + }); + const tzName = formatter.formatToParts(now).find(p => p.type === 'timeZoneName')?.value || ''; + // tzName is like "GMT+8" or "GMT-05:00" + let offset = '+00:00'; + if (tzName) { + const match = tzName.match(/GMT([+-])(\d+)(?::(\d+))?/); + if (match) { + const sign = match[1]; + const hours = match[2].padStart(2, '0'); + const mins = (match[3] || '00').padStart(2, '0'); + offset = `${sign}${hours}:${mins}`; + } + } + return dateStr + offset; + } catch { + // Fallback: use UTC + return now.toISOString(); + } +} + +/** + * Returns today's (or given date's) date string in the configured timezone. + * Example: "2026-05-04" + */ +export function formatLocalDate(date?: Date): string { + const tz = getTimezone(); + const d = date || new Date(); + try { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(d); + const get = (type: string) => parts.find(p => p.type === type)?.value || '00'; + return `${get('year')}-${get('month')}-${get('day')}`; + } catch { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } +} + +/** + * Escape SQL LIKE wildcards (% and _) in user input to prevent unintended pattern matching. + */ +export function escapeLike(str: string): string { + return str.replace(/[%_\\]/g, '\\$&'); +} + +/** + * Returns a local datetime string in the configured timezone. + * Example: "2026-05-04 14:32:23" — intentionally space-separated (no T) for DB compatibility. + */ +export function formatLocalDateTime(date?: Date): string { + const tz = getTimezone(); + const d = date || new Date(); + try { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: tz, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + hour12: false, + }).formatToParts(d); + const get = (type: string) => parts.find(p => p.type === type)?.value || '00'; + return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`; + } catch { + const y = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, '0'); + const da = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const mi = String(d.getMinutes()).padStart(2, '0'); + const s = String(d.getSeconds()).padStart(2, '0'); + return `${y}-${mo}-${da} ${h}:${mi}:${s}`; + } +} diff --git a/packages/backend/src/validation/bounded-pool.ts b/packages/backend/src/validation/bounded-pool.ts new file mode 100755 index 0000000..7401fcf --- /dev/null +++ b/packages/backend/src/validation/bounded-pool.ts @@ -0,0 +1,49 @@ +export class BoundedPool { + private concurrency: number; + private running: number; + private queue: Array<() => Promise>; + + constructor(concurrency: number = 10) { + this.concurrency = concurrency; + this.running = 0; + this.queue = []; + } + + async run(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + const task = async () => { + this.running++; + try { + const result = await fn(); + resolve(result); + } catch (err) { + reject(err); + } finally { + this.running--; + this.processQueue(); + } + }; + + if (this.running < this.concurrency) { + task(); + } else { + this.queue.push(task); + } + }); + } + + private processQueue(): void { + while (this.running < this.concurrency && this.queue.length > 0) { + const task = this.queue.shift(); + if (task) task(); + } + } + + get pending(): number { + return this.queue.length; + } + + get active(): number { + return this.running; + } +} diff --git a/packages/backend/src/validation/link-validator.service.ts b/packages/backend/src/validation/link-validator.service.ts new file mode 100755 index 0000000..23e3ffc --- /dev/null +++ b/packages/backend/src/validation/link-validator.service.ts @@ -0,0 +1,375 @@ +// Native fetch available in Node 20+ +import config from '../config'; +import { RedisClient } from '../middleware/cache'; +import { BoundedPool } from './bounded-pool'; +import { BaiduDriver } from '../cloud/drivers/baidu.driver'; +import { AliyunDriver } from '../cloud/drivers/aliyun.driver'; +import { getSystemConfig } from '../admin/system-config.service'; + +export type LinkStatus = 'valid' | 'invalid' | 'unknown'; + +export interface ValidationResult { + url: string; + status: LinkStatus; + cloudType: string; + checkedAt: string; + message?: string; +} + +/** + * 从系统配置加载自定义关键词列表(一行一条) + */ +function loadCustomKeywords(configKey: string): string[] { + try { + const rules = getSystemConfig(configKey); + if (rules) { + return rules.split('\n').map(k => k.trim()).filter(k => k.length > 0); + } + } catch { + // ignore + } + return []; +} + +export class LinkValidator { + private cache: RedisClient; + private pool: BoundedPool; + + constructor(concurrency?: number) { + this.cache = new RedisClient(); + this.pool = new BoundedPool(concurrency || config.validation.concurrency); + } + + /** + * Validate a single share link — PanSou only, no local fallback. + */ + async validate(url: string, cloudType: string): Promise { + // Check cache first + const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`; + + try { + const cached = await this.cache.get(cacheKey); + if (cached) { + const parsed = JSON.parse(cached); + return parsed as ValidationResult; + } + } catch { + // ignore cache errors + } + + // Try PanSou's /api/check/links + const pansouResult = await this.validateViaPansou(url, cloudType); + if (pansouResult) { + if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') { + // Cache definitive result + const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid; + try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {} + return pansouResult; + } + // PanSou returned locked/unsupported/uncertain → return unknown, no local fallback + return pansouResult; + } + + // PanSou unreachable → return unknown + return { url, status: 'unknown' as LinkStatus, cloudType, checkedAt: new Date().toISOString(), message: '盘搜不可达' }; + } + + /** + * Full validation with local fallback when PanSou can't determine. + */ + async validateWithLocalFallback(url: string, cloudType: string): Promise { + // Check cache first + const cacheKey = `link:valid:${cloudType}:${Buffer.from(url).toString('base64').slice(0, 64)}`; + + try { + const cached = await this.cache.get(cacheKey); + if (cached) { + const parsed = JSON.parse(cached); + return parsed as ValidationResult; + } + } catch { + // ignore cache errors + } + + // Try PanSou + const pansouResult = await this.validateViaPansou(url, cloudType); + if (pansouResult) { + if (pansouResult.status === 'valid' || pansouResult.status === 'invalid') { + const ttl = pansouResult.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid; + try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(pansouResult)); } catch {} + return pansouResult; + } + // PanSou uncertain → fall through to local validation + } + + // Fall back to own validation + let result: ValidationResult; + + switch (cloudType) { + case 'quark': + result = await this.validateQuark(url); + break; + case 'baidu': + result = await this.validateBaidu(url); + break; + case 'aliyun': + result = await this.validateAliyun(url); + break; + default: + result = await this.validateByHtml(url, cloudType); + } + + const ttl = result.status === 'valid' ? config.validation.cacheTtlValid : config.validation.cacheTtlInvalid; + try { await this.cache.setEx(cacheKey, ttl, JSON.stringify(result)); } catch {} + + return result; + } + + /** + * Try PanSou's /api/check/links for validation. + * Returns null if PanSou is unreachable. + * + * Judgment order: + * 1. summary "链接有效" → valid (PanSou's own OK signal) + * 2. summary 含自定义确认关键词 → valid (from DB link_valid_keywords) + * 3. summary 含自定义失效关键词 → invalid (from DB link_invalid_keywords) + * 4. 其他 → unknown + */ + private async validateViaPansou(url: string, cloudType: string): Promise { + const checkedAt = new Date().toISOString(); + try { + const pansouApiUrl = `${config.pansouUrl}/api/check/links`; + const response = await fetch(pansouApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + items: [{ disk_type: cloudType, url }], + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + + const data = await response.json() as any; + const pansouResult = data.results?.[0]; + if (!pansouResult) return null; + + const summary = pansouResult.summary || ''; + + // 1. PanSou 明确返回"链接有效" + if (summary.includes('链接有效')) { + return { url, status: 'valid', cloudType, checkedAt, message: summary }; + } + + // 2. 自定义确认关键词(用户配置的"有效"信号) + const validKeywords = loadCustomKeywords('link_valid_keywords'); + if (validKeywords.some(kw => summary.includes(kw))) { + return { url, status: 'valid', cloudType, checkedAt, message: summary }; + } + + // 3. 自定义失效关键词(用户配置的"失效"信号) + const invalidKeywords = loadCustomKeywords('link_invalid_keywords'); + if (invalidKeywords.some(kw => summary.includes(kw))) { + return { url, status: 'invalid', cloudType, checkedAt, message: summary }; + } + + // 4. 其余全部返回 unknown + return { url, status: 'unknown', cloudType, checkedAt, message: summary || '盘搜无法确认' }; + } catch { + return null; + } + } + + /** + * Validate a Quark share link using the public share token API. + */ + private async validateQuark(url: string): Promise { + const checkedAt = new Date().toISOString(); + + try { + const cleanUrl = url.split('#')[0]; + const urlObj = new URL(cleanUrl); + const pathParts = urlObj.pathname.split('/'); + const shareToken = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]; + + if (!shareToken) { + return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: '无法解析分享链接 token' }; + } + + const tokenUrl = 'https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token?pr=ucpro&fr=pc'; + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Origin': 'https://pan.quark.cn', + 'Referer': 'https://pan.quark.cn/', + }, + body: JSON.stringify({ pwd_id: shareToken, passcode: '' }), + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + const msg = response.status === 403 ? '分享已过期或需要密码' : `HTTP ${response.status}`; + return { url, status: 'invalid', cloudType: 'quark', checkedAt, message: msg }; + } + + const data = await response.json() as any; + if (data.status === 200 && data.data?.stoken) { + const title = data.data?.title || ''; + const author = data.data?.author?.nick_name || ''; + const expiredAt = data.data?.expired_at || 0; + const expireDate = expiredAt > 0 ? new Date(expiredAt).toISOString().slice(0, 10) : ''; + return { + url, + status: 'valid', + cloudType: 'quark', + checkedAt, + message: expireDate ? `有效链接,过期时间: ${expireDate}` : '有效链接', + }; + } + + // API 返回了 200 但无 stoken — 可能是临时异常,保守判 unknown + return { url, status: 'unknown', cloudType: 'quark', checkedAt, message: 'API 返回异常(无 stoken),不做失效判定' }; + } catch (err: any) { + return { + url, + status: 'unknown', + cloudType: 'quark', + checkedAt, + message: `校验异常: ${err.message?.slice(0, 50) || '未知错误'}`, + }; + } + } + + private async validateBaidu(url: string): Promise { + const checkedAt = new Date().toISOString(); + + try { + const driver = new BaiduDriver(); + const result = await driver.validateShareLink(url); + + return { + url, + status: result.valid ? 'valid' : 'invalid', + cloudType: 'baidu', + checkedAt, + message: result.message, + }; + } catch (err: any) { + return { + url, + status: 'unknown', + cloudType: 'baidu', + checkedAt, + message: `校验失败: ${err.message || err}`, + }; + } + } + + private async validateAliyun(url: string): Promise { + const checkedAt = new Date().toISOString(); + + try { + const driver = new AliyunDriver(); + const result = await driver.validateShareLink(url); + + return { + url, + status: result.valid ? 'valid' : 'invalid', + cloudType: 'aliyun', + checkedAt, + message: result.message, + }; + } catch (err: any) { + return { + url, + status: 'unknown', + cloudType: 'aliyun', + checkedAt, + message: `校验失败: ${err.message || err}`, + }; + } + } + + /** + * Fallback: validate by fetching the share page as HTML and checking for + * custom failure keywords from DB config. Used for providers without a + * dedicated API (115, tianyi, 123pan, etc.). + */ + private async validateByHtml(url: string, cloudType: string): Promise { + let status: LinkStatus = 'valid'; + const checkedAt = new Date().toISOString(); + let message = ''; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.validation.timeout); + + const response = await fetch(url, { + signal: controller.signal as any, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + redirect: 'follow', + }); + + clearTimeout(timeoutId); + + const text = await response.text(); + const keywords = loadCustomKeywords('link_invalid_keywords'); + + const isHttpError = response.status >= 400; + if (isHttpError) { + status = 'invalid'; + message = `HTTP ${response.status} ${response.statusText}`; + } else { + const matched = keywords.find(kw => text.includes(kw)); + if (matched) { + status = 'invalid'; + message = `页面包含自定义失效关键词: "${matched}"`; + } else { + message = 'HTML 页面可访问,未检测到失效关键词'; + } + } + } catch (err: any) { + // On timeout or network error, conservatively mark as valid + status = 'valid'; + message = `网络校验超时,保守标记为有效`; + } + + return { url, status, cloudType, checkedAt, message }; + } + + /** + * Batch validate multiple links with bounded concurrency. + */ + async validateBatch(urls: Array<{ url: string; cloudType: string }>): Promise { + const tasks = urls.map(item => () => this.validate(item.url, item.cloudType)); + const results: ValidationResult[] = []; + + for (const task of tasks) { + try { + const result = await this.pool.run(task); + results.push(result); + } catch (err) { + results.push({ + url: '', + status: 'unknown', + cloudType: '', + checkedAt: new Date().toISOString(), + message: '校验执行异常', + }); + } + } + + return results; + } + + async validateBatchWithPool(urls: Array<{ url: string; cloudType: string }>): Promise { + return this.validateBatch(urls); + } +} diff --git a/packages/backend/src/version.ts b/packages/backend/src/version.ts new file mode 100644 index 0000000..e30fdfe --- /dev/null +++ b/packages/backend/src/version.ts @@ -0,0 +1,12 @@ +/** + * CloudSearch 应用版本号 + * + * 版本管理规则: + * - 每次小优化/修复:patch +1 (0.0.1 → 0.0.2) + * - 20 次 patch 后:minor +1, patch 归零 (0.1.0) + * - 10 次 minor 后:major +1, minor 归零 (1.0.0) + * + * 修改此文件的同时请同步更新后端 package.json 中的 version 字段。 + */ + +export const APP_VERSION = "0.0.2"; diff --git a/packages/backend/src/video/video.service.ts b/packages/backend/src/video/video.service.ts new file mode 100755 index 0000000..6242901 --- /dev/null +++ b/packages/backend/src/video/video.service.ts @@ -0,0 +1,37 @@ +// Native fetch available in Node 20+ +import config from '../config'; + +export interface VideoInfo { + title: string; + coverUrl: string; + videoUrl: string; + author: string; + platform: string; + duration?: string; +} + +export async function parseVideo(url: string): Promise { + const apiUrl = `${config.videoParserUrl}/parse`; + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(30000), + }); + + if (!response.ok) { + throw new Error(`Video parser API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as any; + + return { + title: data.title || '', + coverUrl: data.coverUrl || data.cover || '', + videoUrl: data.videoUrl || data.url || data.video || '', + author: data.author || data.nickname || '', + platform: data.platform || '', + duration: data.duration || '', + }; +} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json new file mode 100755 index 0000000..1951778 --- /dev/null +++ b/packages/backend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/frontend/HomePage.vue b/packages/frontend/HomePage.vue new file mode 100755 index 0000000..4c66a7d --- /dev/null +++ b/packages/frontend/HomePage.vue @@ -0,0 +1,351 @@ + + + + + diff --git a/packages/frontend/SearchResult.vue b/packages/frontend/SearchResult.vue new file mode 100755 index 0000000..b461ab1 --- /dev/null +++ b/packages/frontend/SearchResult.vue @@ -0,0 +1,2209 @@ + + + + + diff --git a/packages/frontend/h5/app.js b/packages/frontend/h5/app.js new file mode 100644 index 0000000..6ba0137 --- /dev/null +++ b/packages/frontend/h5/app.js @@ -0,0 +1,601 @@ + // ===== Anime keywords for categorization ===== + const ANIME_KWS=['仙逆','凡人修仙传','斗破苍穹','斗破','盘龙','完美世界','一念永恒','妖神记','星辰变','遮天','神墓','吞噬星空','武动乾坤','大主宰','全职高手','鬼灭之刃','海贼王','火影忍者','死神','龙珠','进击的巨人','咒术回战','一人之下','狐妖小红娘','魔道祖师','天官赐福','时光代理人','大王饶命','斗罗大陆','绝世唐门','不良人','秦时明月','全职法师','牧神记','三体','灵笼','雾山五行','凡人','仙王的日常生活','百妖谱','眷思量','镖人','伍六七','刺客伍六七','葬送的芙莉莲','间谍过家家'] + + // ===== Quotes ===== + const QUOTES=['学而时习之,不亦说乎。','温故而知新,可以为师矣。','三人行,必有我师焉。','学而不思则罔,思而不学则殆。','博学之,审问之,慎思之,明辨之,笃行之。','千里之行,始于足下。','不积跬步,无以至千里。','知之为知之,不知为不知,是知也。','工欲善其事,必先利其器。','玉不琢,不成器;人不学,不知道。','学以致用,知行合一。','学海无涯,勤作舟。','书山有路,勤为径。','宝剑锋从磨砺出,梅花香自苦寒来。','锲而不舍,金石可镂。','业精于勤,荒于嬉。','读书破万卷,下笔如有神。','路漫漫其修远兮,吾将上下而求索。','采菊东篱下,悠然见南山。','海内存知己,天涯若比邻。','长风破浪会有时,直挂云帆济沧海。','会当凌绝顶,一览众山小。','山重水复疑无路,柳暗花明又一村。'] + + // ===== Home Page ===== + function homeSearch(){ + const q=document.getElementById('homeSearchInput').value.trim() + if(q)doSearchFromHome(q) + } + + function doSearchFromHome(q){ + document.getElementById('homePage').style.display='none' + document.getElementById('searchView').style.display='block' + document.getElementById('searchInput').value=q + window.history.replaceState({},'','/h5?q='+encodeURIComponent(q)) + doSearch() + } + function renderHomePage(data){ + fetch("/api/site-config").then(r=>r.json()).then(cfg=>{ + // 显示 Logo(优先图片,其次文字) + var logoEl=document.getElementById("homeLogo"); + var headerEl=document.getElementById("headerTitle"); + if(cfg.site_logo){ + logoEl.innerHTML='logo'; + logoEl.style.display=""; + headerEl.innerHTML='logo'; + headerEl.style.display=""; + }else if(cfg.site_name){ + logoEl.textContent=cfg.site_name; + logoEl.style.display=""; + headerEl.textContent=cfg.site_name; + headerEl.style.display=""; + }else{ + logoEl.textContent="CloudSearch"; + logoEl.style.display=""; + headerEl.textContent="CloudSearch"; + headerEl.style.display=""; + } + if(cfg.site_disclaimer){ + document.getElementById("footerContent").innerHTML=cfg.site_disclaimer.replace(/\n/g,'
'); + document.getElementById("siteFooter").style.display="block"; + } + }).catch(()=>{}) + const categories=data.categories||[] + const fetchedAt=data.fetchedAt||'' + // Quote + fetch('https://v1.hitokoto.cn/').then(r=>r.json()).then(d=>{ + document.getElementById('homeQuote').textContent='「 '+d.hitokoto+' 」' + document.getElementById('homeQuoteAuthor').textContent='---'+(d.from_who||d.from||'') + }).catch(()=>{ + document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」' + document.getElementById('homeQuoteAuthor').textContent='---孔子' + }) + + // Store expanded state per category + window.__expanded=window.__expanded||{} + window.__activeTab=window.__activeTab||{} + + const el=document.getElementById('homeRankings') + let html='' + for(const cat of categories){ + const icons={movie:'🎬',tv:'📺',western_movie:'🎥',western:'🌍',donghua:'🐉',global_anime:'🌐',variety:'🎤',niche:'💎',hotsite:'🏆'} + const icon=icons[cat.category]||'📋' + const key=cat.category + if(!window.__activeTab[key])window.__activeTab[key]='hot' + + html+='
' + html+='
'+ + ''+icon+' '+cat.label+''+ + '
'+ + '热榜'+ + '最新'+ + '
'+ + '
' + html+='
' + const items=window.__activeTab[key]==='hot'?(cat.hot||[]):(cat.newest||[]) + html+=renderRankItems(items,key,false) + html+='
' + // 数据来源 + html+='
'+ + ''+(cat.category!=='hotsite'?'数据来源:TMDB':'本站搜索数据')+''+ + ''+fetchedAt+''+ + '
' + } + el.innerHTML=html + } + + function renderRankItems(items,key,expanded){ + if(!items||items.length===0)return'
暂无数据
' + const limit=3 + const show=expanded?items.length:Math.min(limit,items.length) + let html=items.slice(0,show).map((item,i)=>{ + const c=i<3?' rank-idx top3':' rank-idx' + return '
'+ + ''+(i+1)+''+ + ''+item.keyword+''+ + ''+(item.rating?'⭐'+item.rating:item.searchCount)+''+ + '
' + }).join('') + if(items.length>limit&&!expanded){ + html+='
展开全部 ▼
' + } + return html + } + + function expandRank(key){ + const container=document.getElementById('ritems-'+key) + if(!container)return + const tab=window.__activeTab[key]||'hot' + const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest) + container.innerHTML=renderRankItems(data.items,key,true) + } + + function switchRankTab(category,tab){ + window.__activeTab[category]=tab + const tabsContainer=document.getElementById('rtabs-'+category) + if(tabsContainer){ + tabsContainer.querySelectorAll('.rank-tab').forEach(t=>t.className='rank-tab') + tabsContainer.querySelector(tab==='hot'?'.rank-tab:first-child':'.rank-tab:last-child').className='rank-tab active' + } + const container=document.getElementById('ritems-'+category) + if(container){ + const data=JSON.parse(tab==='hot'?container.dataset.hot:container.dataset.newest) + container.innerHTML=renderRankItems(data.items,category,false) + } + } + let userInfo = null + let allResults = [] + let allChannels = [] + let activeTab = '' + let currentSaveItem = null + const CLOUD_ICONS = {quark:'☁️',baidu:'🔵',aliyun:'🟠','115':'🟣',tianyi:'🔷','123pan':'🔴',uc:'🟡',xunlei:'🟢',pikpak:'🟤',magnet:'🧲',ed2k:'🔗',others:'📁'} + const CLOUD_LABELS = {quark:'夸克网盘',baidu:'百度网盘',aliyun:'阿里云盘','115':'115网盘',tianyi:'天翼云盘','123pan':'123云盘',uc:'UC网盘',xunlei:'迅雷云盘',pikpak:'PikPak',magnet:'磁力链接',ed2k:'电驴链接',others:'其他'} + const CLOUD_COLORS = {quark:'#07c160',baidu:'#4e6ef2',aliyun:'#ff6a00','115':'#9b59b6',tianyi:'#00a1d6','123pan':'#e74c3c',uc:'#f39c12',xunlei:'#2ecc71',pikpak:'#8e44ad',magnet:'#95a5a6',ed2k:'#7f8c8d',others:'#95a5a6'} + const CLOUD_ORDER = {quark:1,baidu:2,aliyun:3,'115':4,tianyi:5,'123pan':6,uc:7,xunlei:8,pikpak:9,magnet:10,ed2k:11,others:12} + + // ===== Fetch helpers ===== + function getToken(){return localStorage.getItem('h5_admin_token')} + function apiHeaders(){const h={'Content-Type':'application/json'};const t=getToken();if(t)h['Authorization']='Bearer '+t;return h} + + // ===== Toast ===== + let toastTimer + function showToast(msg,isError){ + const el=document.getElementById('toast') + el.textContent=msg + el.className='toast show'+(isError?' error':'') + clearTimeout(toastTimer) + toastTimer=setTimeout(()=>el.className='toast',2000) + } + + // ===== User ===== + async function checkLogin(){ + try{ + const res=await fetch('/api/me',{headers:apiHeaders()}) + if(res.ok){ + const data=await res.json() + if(data.loggedIn){ + userInfo=data + document.getElementById('userArea').innerHTML=''+data.username+'' + } + } + }catch(e){} + } + + function logout(){ + localStorage.removeItem('h5_admin_token') + userInfo=null + document.getElementById('userArea').innerHTML='' + showToast('已退出') + } + + function showLogin(){ + document.getElementById('loginErr').textContent='' + document.getElementById('loginUser').value='' + document.getElementById('loginPass').value='' + document.getElementById('loginModal').style.display='block' + document.getElementById('overlay').style.display='block' + } + + function closeLogin(){ + document.getElementById('loginModal').style.display='none' + document.getElementById('overlay').style.display='none' + } + + async function handleLogin(){ + const user=document.getElementById('loginUser').value.trim() + const pass=document.getElementById('loginPass').value + if(!user||!pass){showToast('请输入用户名和密码',true);return} + const btn=document.getElementById('loginBtn') + btn.disabled=true;btn.textContent='登录中...' + try{ + const res=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:user,password:pass})}) + if(res.ok){ + const data=await res.json() + localStorage.setItem('h5_admin_token',data.token) + userInfo={username:user} + document.getElementById('userArea').innerHTML=''+user+'' + closeLogin() + showToast('登录成功') + }else{ + const err=await res.json().catch(()=>({})) + document.getElementById('loginErr').textContent=err.error||'登录失败' + } + }catch(e){ + document.getElementById('loginErr').textContent='网络错误' + }finally{ + btn.disabled=false;btn.textContent='登录' + } + } + + // ===== Search ===== + function handleKeydown(e){if(e.key==='Enter')doSearch()} + + let searchTimer + function doSearch(){ + const q=document.getElementById('searchInput').value.trim() + if(!q)return + // Update URL + window.history.replaceState({},'','/h5?q='+encodeURIComponent(q)) + // Show loading + document.getElementById('results').innerHTML='' + document.getElementById('tabs').style.display='none' + document.getElementById('infoBar').style.display='none' + document.getElementById('loading').style.display='block' + document.getElementById('loadingText').textContent='🔍 正在搜索中...' + document.getElementById('searchBtn').disabled=true + + let progress=0 + const bar=document.getElementById('loadingBar') + const progressTimer=setInterval(()=>{ + if(progress<60)progress+=1+Math.random()*3 + else if(progress<85)progress+=0.5+Math.random() + bar.style.width=progress+'%' + },200) + + // Use streaming search for live updates + streamSearch(q,progressTimer,bar) + } + + async function streamSearch(q,progressTimer,bar){ + const startTime=Date.now() + try{ + const response=await fetch('/api/query',{method:'POST',headers:apiHeaders(),body:JSON.stringify({q})}) + if(!response.ok)throw new Error('搜索失败 ('+response.status+')') + + const reader=response.body.getReader() + const decoder=new TextDecoder() + let buffer='' + let allItems=[] + let channels=[] + let totalCount=0 + let filteredCount=0 + + while(true){ + const {done,value}=await reader.read() + if(done)break + + buffer+=decoder.decode(value,{stream:true}) + const lines=buffer.split('\n') + buffer=lines.pop()||'' + + for(const line of lines){ + if(!line.trim())continue + try{ + const msg=JSON.parse(line) + if(msg.type==='stats'){ + totalCount=msg.total||0 + filteredCount=msg.filtered||0 + document.getElementById('loadingText').textContent='🔍 搜索到 '+totalCount+' 条,正在验证...' + }else if(msg.type==='result'){ + if(msg.valid&&msg.id){ + allItems.push(msg.id) + } + }else if(msg.type==='complete'){ + const results=msg.results||[] + channels=msg.channels||[] + clearInterval(progressTimer) + bar.style.width='100%' + setTimeout(()=>renderResults(results,channels,totalCount,filteredCount,Date.now()-startTime),300) + return + } + }catch(e){} + } + } + }catch(e){ + clearInterval(progressTimer) + document.getElementById('loading').style.display='none' + document.getElementById('searchBtn').disabled=false + document.getElementById('results').innerHTML='
搜索失败:'+e.message+'
' + } + } + + // ===== Render ===== + function renderResults(results,channels,totalCount,filteredCount,time){ + document.getElementById('loading').style.display='none' + document.getElementById('searchBtn').disabled=false + allResults=results + allChannels=channels||[] + + // Info bar + if(totalCount>0){ + document.getElementById('infoBar').style.display='flex' + document.getElementById('infoCount').textContent='已为您挑选到最符合 '+totalCount+' 条结果' + document.getElementById('infoTime').textContent='⏱ '+time+'ms' + if(filteredCount>0)document.getElementById('infoFiltered').textContent='❌ 失效 '+filteredCount + else document.getElementById('infoFiltered').textContent='' + }else{ + document.getElementById('infoBar').style.display='none' + } + + // Build tabs + const tabsEl=document.getElementById('tabs') + tabsEl.innerHTML='' + const typeCounts={} + for(const r of results){const ct=r.cloud_type||'others';typeCounts[ct]=(typeCounts[ct]||0)+1} + const sorted=Object.keys(typeCounts).sort((a,b)=>(CLOUD_ORDER[a]||99)-(CLOUD_ORDER[b]||99)) + // "全部" tab + const allTab=document.createElement('div') + allTab.className='tab active' + allTab.textContent='📋 全部 ('+results.length+')' + allTab.onclick=()=>{setActiveTab('');renderCardList(results)} + tabsEl.appendChild(allTab) + for(const ct of sorted){ + const tab=document.createElement('div') + tab.className='tab' + tab.textContent=(CLOUD_ICONS[ct]||'📁')+' '+(CLOUD_LABELS[ct]||ct)+' ('+typeCounts[ct]+')' + tab.onclick=()=>{setActiveTab(ct);renderCardList(results.filter(r=>(r.cloud_type||'others')===ct))} + tabsEl.appendChild(tab) + } + tabsEl.style.display=results.length>0?'flex':'none' + activeTab='' + + // Render cards + renderCardList(results) + } + + function setActiveTab(ct){ + activeTab=ct + document.querySelectorAll('.tab').forEach((t,i)=>{ + const isAll=i===0&&!ct + const active=i>0&&ct&&t.textContent.includes(CLOUD_LABELS[ct]) + t.className='tab'+(active||isAll?' active':'') + }) + } + + function renderCardList(items){ + const el=document.getElementById('results') + if(items.length===0){ + el.innerHTML='
暂无结果
' + return + } + el.innerHTML=items.map((item,idx)=>{ + const coverHtml=item.cover + ? '' + : '
'+escapeHtml(CLOUD_ICONS[item.cloud_type||'others'])+'
' + const cloudLabel=CLOUD_LABELS[item.cloud_type]||item.cloud_type||'' + const cloudColor=CLOUD_COLORS[item.cloud_type]||'#95a5a6' + const tags=extractTags(item.title||'') + const cleanTitle=(item.title||'').replace(/【[^】]+】/g,'').trim() + const relativeTime=formatTime(item.update_time||item.datetime||'') + return '
'+ + '
'+coverHtml+''+cloudLabel+'
'+ + '
'+ + '
'+escapeHtml(cleanTitle)+'
'+ + '
🕐 '+relativeTime+''+(item.file_size?'📦 '+escapeHtml(item.file_size)+'':'')+'
'+ + (tags.length>0?'
'+tags.map(t=>''+escapeHtml(t)+'').join('')+'
':'')+ + '
'+ + ''+(item.source?escapeHtml(item.source):'网盘')+''+ + ''+ + '
'+ + '
'+ + '
' + }).join('') + + // Store items for save reference + window.__h5Results=items + } + + function escapeHtml(s){if(!s)return '';return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} + + function extractTags(title){ + const tags=[] + // Quality tags + const quality=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','WEB-DL','WEBRip'] + for(const q of quality){if(title.includes(q)&&!tags.includes(q))tags.push(q)} + const kw=['杜比视界','杜比全景声','高码率','内封简繁英字幕','内嵌字幕','中文字幕','中英字幕'] + for(const k of kw){if(title.includes(k)&&!tags.includes(k))tags.push(k)} + return tags.slice(0,6) + } + + function isQualityTag(t){const q=['4K','1080P','2160P','720P','480P','HDR','HDR10','BluRay','REMUX','HEVC','x264','x265','臻彩','高清','WEB-DL','WEBRip'];return q.includes(t)} + + function formatTime(s){ + if(!s)return '' + const d=new Date(s) + if(isNaN(d.getTime()))return s.slice(0,10) + const diff=Date.now()-d.getTime() + if(diff<0)return s.slice(0,10) + const mins=Math.floor(diff/60000) + if(mins<60)return mins<=1?'刚刚':mins+' 分钟前' + const hours=Math.floor(mins/60) + if(hours<24)return hours+' 小时前' + const days=Math.floor(hours/24) + if(days<30)return days+' 天前' + return Math.floor(days/30)+' 个月前' + } + + // ===== Save / Share ===== + function saveItem(idx){ + const items=window.__h5Results||[] + currentSaveItem=items[idx] + if(!currentSaveItem)return + + document.getElementById('progressSteps').style.display='block' + document.getElementById('shareContent').style.display='none' + document.getElementById('saveError').style.display='none' + document.getElementById('copyBtn2').style.display='none' + + const title=(currentSaveItem.title||'').replace(/【[^】]+】/g,'').trim()||'资源' + document.getElementById('shareTitle').textContent=title + + // Show modal + document.getElementById('overlay').style.display='block' + document.getElementById('shareModal').style.display='block' + + // Reset steps + resetSteps() + advanceStep(1) + + // Call save API + doSave() + } + + async function doSave(){ + try{ + const res=await fetch('/api/save',{method:'POST',headers:apiHeaders(),body:JSON.stringify({type:'search',source:currentSaveItem,target_cloud:currentSaveItem.cloud_type||'quark'})}) + const data=await res.json() + + if(!data.success){ + document.getElementById('progressSteps').style.display='none' + document.getElementById('saveError').style.display='flex' + document.getElementById('saveError').textContent=data.message||data.error||'保存失败' + return + } + + // Step 2 + advanceStep(2) + await sleep(500) + + // Step 3 + advanceStep(3) + await sleep(300) + + if(data.share_url){ + advanceStep(4) + await sleep(200) + showShareResult(data) + }else{ + advanceStep(4) + document.getElementById('progressSteps').style.display='none' + document.getElementById('saveError').style.display='flex' + document.getElementById('saveError').textContent='生成分享链接失败' + } + }catch(e){ + document.getElementById('progressSteps').style.display='none' + document.getElementById('saveError').style.display='flex' + document.getElementById('saveError').textContent=e.message||'保存请求失败' + } + } + + function showShareResult(data){ + document.getElementById('progressSteps').style.display='none' + document.getElementById('shareContent').style.display='block' + + const link=data.share_url + document.getElementById('shareLinkInput').value=link + + const diskLabel=CLOUD_LABELS[currentSaveItem.cloud_type]||'夸克网盘' + document.getElementById('qrLabel').textContent=diskLabel+' APP扫码转存' + + // Generate QR + const qrContainer=document.getElementById('qrContainer') + qrContainer.innerHTML='' + new QRCode(qrContainer,{text:link,width:140,height:140}) + + // Password + const pwd=data.share_pwd||data.sharePwd||'' + if(pwd){ + document.getElementById('sharePwdRow').style.display='flex' + document.getElementById('sharePwdTag').textContent=pwd + }else{ + document.getElementById('sharePwdRow').style.display='none' + } + + document.getElementById('copyBtn2').style.display='inline-block' + } + + function resetSteps(){ + for(let i=1;i<=3;i++){ + const el=document.getElementById('step'+i) + el.className='step' + el.querySelector('.step-dot').innerHTML=''+i+'' + el.querySelector('.step-status').textContent='等待中' + el.querySelector('.step-status').className='step-status wait' + } + } + + function advanceStep(n){ + for(let i=1;i<=3;i++){ + const el=document.getElementById('step'+i) + if(i' + const titles=['正在转存到','正在重命名文件(防和谐)','正在生成分享链接'] + el.querySelector('.step-title').textContent=titles[i-1]+'...' + el.querySelector('.step-status').textContent='进行中' + el.querySelector('.step-status').className='step-status doing' + } + } + } + + function sleep(ms){return new Promise(r=>setTimeout(r,ms))} + + function copyShareLink(){ + const input=document.getElementById('shareLinkInput') + if(!input.value)return + if(navigator.clipboard&&navigator.clipboard.writeText){ + navigator.clipboard.writeText(input.value).then(()=>showToast('链接已复制')).catch(()=>fallbackCopy(input.value)) + }else{ + fallbackCopy(input.value) + } + } + + function fallbackCopy(text){ + const ta=document.createElement('textarea') + ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';document.body.appendChild(ta) + ta.select() + try{document.execCommand('copy');showToast('链接已复制')}catch{showToast('复制失败',true)} + document.body.removeChild(ta) + } + + function openDisclaimer(){ + window.open('/disclaimer/','_blank') + } + + function closeModal(){ + document.getElementById('overlay').style.display='none' + document.getElementById('shareModal').style.display='none' + document.getElementById('loginModal').style.display='none' + } + + // ===== Init ===== + checkLogin() + + // Add Enter key handler for home search + document.getElementById('homeSearchInput').addEventListener('keydown',function(e){if(e.key==='Enter')homeSearch()}) + // Also add for search view input + document.getElementById('searchInput').addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()}) + + // Fetch home page data + fetch('/api/rankings/categorized').then(r=>r.json()).then(data=>{ + renderHomePage(data) + }).catch(()=>{ + document.getElementById('homeQuote').textContent='「 学而时习之,不亦说乎。 」' + document.getElementById('homeQuoteAuthor').textContent='---孔子' + }) + + // Check URL for query + const params=new URLSearchParams(window.location.search) + const q=params.get('q') + if(q){ + document.getElementById('homePage').style.display='none' + document.getElementById('searchView').style.display='block' + document.getElementById('searchInput').value=q + doSearch() + } + +// ===== Dark Mode Toggle ===== +(function() { + var btn = document.createElement('button'); + btn.className = 'theme-btn'; + btn.title = '切换暗色模式'; + var isDark = localStorage.getItem('h5_theme') === 'dark'; + if (!isDark && window.matchMedia('(prefers-color-scheme: dark)').matches) isDark = true; + btn.textContent = isDark ? '☀️' : '🌙'; + if (isDark) document.documentElement.setAttribute('data-theme', 'dark'); + btn.onclick = function() { + var dark = document.documentElement.getAttribute('data-theme') !== 'dark'; + document.documentElement.setAttribute('data-theme', dark ? 'dark' : ''); + localStorage.setItem('h5_theme', dark ? 'dark' : 'light'); + btn.textContent = dark ? '☀️' : '🌙'; + }; + document.body.appendChild(btn); +})(); diff --git a/packages/frontend/h5/index.html b/packages/frontend/h5/index.html new file mode 100755 index 0000000..b302107 --- /dev/null +++ b/packages/frontend/h5/index.html @@ -0,0 +1,160 @@ + + + + + + + + + CloudSearch - 搜索 + + + + + +
+ +
+
+ + +
+
+
+
+
+ + + +
+ + +
+ + + + + + diff --git a/packages/frontend/h5/style.css b/packages/frontend/h5/style.css new file mode 100644 index 0000000..718f821 --- /dev/null +++ b/packages/frontend/h5/style.css @@ -0,0 +1,179 @@ +/* ===== Dark Mode ===== */[data-theme="dark"] body { background:#1a1a1a;color:#e5e5e5 }[data-theme="dark"] .rank-block,[data-theme="dark"] .card,[data-theme="dark"] .modal,[data-theme="dark"] .header { background:#1f1f1f;border-color:#333 }[data-theme="dark"] input,[data-theme="dark"] .home-search-box,[data-theme="dark"] .search-wrap { background:#2a2a2a;color:#e5e5e5;border-color:#333 }[data-theme="dark"] .rank-block-title,[data-theme="dark"] .card-title,[data-theme="dark"] .rank-name,[data-theme="dark"] .modal-hdr { color:#e5e5e5 }[data-theme="dark"] .card-meta,[data-theme="dark"] .rank-cnt,[data-theme="dark"] .info-bar { color:#999 }[data-theme="dark"] .rank-tab,[data-theme="dark"] .tab { background:#333;color:#999 }[data-theme="dark"] .rank-tab.active,[data-theme="dark"] .tab.active { background:#409eff;color:#fff }[data-theme="dark"] .site-footer { background:#1a1a1a;border-color:#333;color:#999 }[data-theme="dark"] .rank-block-ftr { border-color:#333;color:#666 }[data-theme="dark"] .rank-idx { background:#333;color:#999 }[data-theme="dark"] .theme-btn { position:fixed;bottom:20px;right:20px;z-index:99;width:40px;height:40px;border-radius:50%;border:1px solid #555;background:#1f1f1f;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.3) } + /* ===== Reset & Base ===== */ + *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} + html{font-size:16px;-webkit-text-size-adjust:100%} + body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:#f5f5f5;color:#303133;min-height:100vh;overflow-x:hidden} + :root{--primary:#409eff;--primary-dark:#337ecc;--primary-light:rgba(64,158,255,0.08);--text:#303133;--text2:#909399;--border:#ebeef5;--bg:#f5f5f5;--white:#fff;--radius:10px;--shadow:0 1px 4px rgba(0,0,0,0.04);--safe-bottom:env(safe-area-inset-bottom,0px)} + a{color:var(--primary);text-decoration:none} + img{display:block;max-width:100%} + + /* ===== Home Page ===== */ + .home-page{padding-bottom:calc(30px + var(--safe-bottom))} + .home-hero{display:flex;flex-direction:column;align-items:center;padding:36px 16px 20px} + .home-logo{font-size:32px;font-weight:700;color:var(--primary);margin-bottom:20px;text-align:center} + .home-logo-img{max-width:360px;max-height:80px;width:auto;height:auto;object-fit:contain} + .home-search-box{display:flex;width:100%;max-width:500px;border:1px solid var(--border);border-radius:20px;overflow:hidden;background:var(--bg);transition:border-color .2s} + .home-search-box:focus-within{border-color:var(--primary);background:var(--white);box-shadow:0 0 0 3px rgba(64,158,255,.1)} + .home-search-box input{flex:1;height:40px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent} + .home-search-box button{flex-shrink:0;height:32px;margin:4px;padding:0 22px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer} + .home-search-box button:active{background:var(--primary-dark)} + .home-quote{margin-top:12px;font-size:12px;color:#b0b8c4;font-style:italic;text-align:center;max-width:500px;line-height:1.5} + .home-quote-author{font-size:11px;color:#c0c4cc;display:inline-block;margin-top:2px} + + /* ===== Home Rankings ===== */ + .home-rankings{padding:8px 12px;display:flex;flex-direction:column;gap:10px} + .rank-block{background:var(--white);border-radius:var(--radius);padding:12px;border:1px solid var(--border);box-shadow:var(--shadow)} + .rank-block-hdr{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:2px solid #f0f0f0;margin-bottom:4px} + .rank-block-title{font-size:14px;font-weight:700;color:var(--text);white-space:nowrap} + .rank-block-tabs{display:flex;gap:2px;background:#f0f2f5;border-radius:5px;padding:2px} + .rank-tab{font-size:11px;padding:2px 9px;border-radius:4px;cursor:pointer;color:#909399;font-weight:500;transition:all .2s;user-select:none} + .rank-tab.active{background:var(--white);color:var(--primary);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)} + .rank-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;cursor:pointer;transition:background .15s} + .rank-item:active{background:#f0f5ff} + .rank-idx{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#909399;background:#f0f0f0;flex-shrink:0} + .rank-idx.top3{background:var(--primary);color:var(--white);font-size:12px} + .rank-name{flex:1;min-width:0;font-size:13px;font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .rank-cnt{font-size:11px;color:#c0c4cc;white-space:nowrap;flex-shrink:0} + .rank-expand{text-align:center;padding:5px;margin-top:2px;font-size:12px;color:var(--primary);cursor:pointer;border-radius:5px;user-select:none} + .rank-expand:active{background:#ecf5ff} + .rank-block-ftr{margin-top:6px;padding-top:6px;border-top:1px solid #f0f0f0;display:flex;align-items:center;justify-content:space-between;font-size:10px;color:#c0c4cc} + .ftr-time{font-family:monospace;font-size:9px} + + /* ===== Layout ===== */ + .app{max-width:100%;margin:0 auto;padding-bottom:calc(20px + var(--safe-bottom))} + .header{position:sticky;top:0;z-index:50;background:var(--white);border-bottom:1px solid var(--border);padding:8px 12px} + .header-row{display:flex;align-items:center;gap:10px} + .header-title{font-size:18px;font-weight:700;color:var(--primary);flex-shrink:0} + .header-title-link{text-decoration:none;flex-shrink:0;display:flex;align-items:center} + .header-logo-img{max-width:120px;max-height:28px;width:auto;height:auto;object-fit:contain;display:block} + .header-actions{margin-left:auto;display:flex;gap:6px;align-items:center} + + /* ===== Search Bar ===== */ + .search-wrap{flex:1;display:flex;border:1px solid var(--border);border-radius:18px;overflow:hidden;background:var(--bg);transition:border-color .2s} + .search-wrap:focus-within{border-color:var(--primary);background:var(--white)} + .search-wrap input{flex:1;height:36px;border:none;padding:0 14px;font-size:14px;outline:none;background:transparent} + .search-wrap button{flex-shrink:0;height:28px;margin:4px;padding:0 18px;border:none;border-radius:999px;background:var(--primary);color:var(--white);font-size:13px;font-weight:600;cursor:pointer;transition:background .2s} + .search-wrap button:active{background:var(--primary-dark)} + .search-wrap button:disabled{opacity:.5} + + /* ===== Footer ===== */ + .site-footer{margin-top:30px;padding:16px 12px 24px;background:#f9fafb;border-top:1px solid var(--border)} + .footer-inner{max-width:500px;margin:0 auto;font-size:11px;line-height:1.8;color:#909399;text-align:center;white-space:pre-line} + .footer-actions{display:flex;justify-content:center;gap:10px;margin-top:12px;flex-wrap:wrap} + .footer-btn{padding:8px 20px;border:1px solid var(--border);border-radius:8px;background:var(--white);color:var(--text);font-size:13px;cursor:pointer;transition:all .2s} + .footer-btn:active{background:var(--primary-light);border-color:var(--primary);color:var(--primary)} + + /* ===== Info Bar ===== */ + .info-bar{display:flex;align-items:center;gap:8px;padding:10px 12px 0;font-size:12px;color:var(--text2);flex-wrap:wrap} + .info-bar .count{font-weight:600;color:var(--text)} + .info-bar .time{font-family:monospace;background:#f4f4f5;padding:1px 6px;border-radius:4px} + .info-bar .badge-err{background:#fef0f0;color:#f56c6c;padding:1px 6px;border-radius:4px} + + /* ===== Loading ===== */ + .loading{padding:24px 12px;text-align:center;font-size:13px;color:var(--text2)} + .loading-bar{width:100%;height:3px;background:#e8e8e8;border-radius:2px;overflow:hidden;margin-top:8px} + .loading-bar-inner{height:100%;background:linear-gradient(90deg,var(--primary),#67c23a);border-radius:2px;transition:width .3s ease;width:0%} + + /* ===== Tabs ===== */ + .tabs{display:flex;gap:4px;padding:8px 12px;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none} + .tabs::-webkit-scrollbar{display:none} + .tab{flex-shrink:0;padding:5px 12px;border-radius:16px;font-size:12px;color:#606266;background:#f0f2f5;cursor:pointer;white-space:nowrap;transition:all .2s;user-select:none} + .tab:active{transform:scale(.95)} + .tab.active{background:var(--primary-light);color:var(--primary);font-weight:600} + + /* ===== Results ===== */ + .results{display:flex;flex-direction:column;gap:10px;padding:8px 12px} + .empty{padding:40px 12px;text-align:center;color:var(--text2);font-size:14px} + + /* ===== Card ===== */ + .card{display:flex;gap:10px;background:var(--white);border-radius:var(--radius);padding:10px;border:1px solid var(--border);transition:border-color .2s} + .card:active{border-color:#c0c4cc} + .card-cover{flex-shrink:0;width:90px;height:120px;border-radius:8px;overflow:hidden;background:var(--bg);position:relative} + .card-cover img{width:100%;height:100%;object-fit:cover} + .card-cover .placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px;background:linear-gradient(135deg,#667eea,#764ba2);color:rgba(255,255,255,.6)} + .card-cover .tag{position:absolute;bottom:3px;left:3px;padding:1px 5px;border-radius:3px;color:#fff;font-size:10px;font-weight:600;backdrop-filter:blur(2px)} + .card-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px} + .card-title{font-size:14px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden} + .card-meta{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:8px} + .card-meta .size{color:#67c23a} + .card-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px} + .card-tags span{font-size:10px;padding:1px 6px;border-radius:4px;background:#ecf5ff;color:#409eff;white-space:nowrap} + .card-tags .quality{background:#fef0f0;color:#e74c3c} + .card-actions{margin-top:auto;display:flex;align-items:center;justify-content:space-between;gap:6px} + .card-source{font-size:10px;color:var(--text2);background:#f4f4f5;padding:1px 6px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px} + .card-btn{padding:4px 10px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;transition:background .2s} + .card-btn:active{background:var(--primary-dark)} + .card-btn:disabled{opacity:.5} + + /* ===== Toast ===== */ + .toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.75);color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;z-index:200;pointer-events:none;opacity:0;transition:opacity .3s} + .toast.show{opacity:1} + .toast.error{background:rgba(245,108,108,.9)} + + /* ===== Overlay / Modal ===== */ + .overlay{position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:16px;animation:fadeIn .2s} + .modal{background:var(--white);border-radius:14px;width:100%;max-width:420px;max-height:90vh;overflow-y:auto;padding:0;animation:slideUp .25s} + .modal-hdr{padding:14px 16px;border-bottom:1px solid var(--border);font-size:15px;font-weight:700;color:var(--text)} + .modal-body{padding:14px 16px} + .modal-ftr{padding:10px 16px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end} + .modal-ftr button{height:36px;padding:0 16px;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer} + .modal-ftr .btn-close{background:#f4f4f5;color:#606266} + .modal-ftr .btn-disclaimer{background:#fdf6ec;color:#d46b08;margin-right:auto} + .modal-ftr .btn-primary{background:var(--primary);color:var(--white)} + .modal-ftr .btn-primary:active{background:var(--primary-dark)} + @keyframes fadeIn{from{opacity:0}to{opacity:1}} + @keyframes slideUp{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}} + + /* ===== Share Modal ===== */ + .share-section{display:flex;flex-direction:column;gap:10px} + .share-row{display:flex;gap:8px} + .share-row input{flex:1;height:36px;border:1px solid var(--border);border-radius:6px;padding:0 10px;font-size:13px;outline:none;background:var(--bg);color:var(--text)} + .share-row input:focus{border-color:var(--primary)} + .share-row .copy-btn{height:36px;padding:0 12px;border:none;border-radius:6px;background:var(--primary);color:var(--white);font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap} + .share-pwd{display:flex;align-items:center;gap:6px;font-size:13px} + .share-pwd .pwd-tag{padding:2px 8px;background:#fdf6ec;color:#e6a23c;border-radius:4px;font-weight:700} + .share-pwd .pwd-hint{font-size:11px;color:var(--text2)} + .share-tip{padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;line-height:1.5;color:#d46b08;display:flex;gap:6px;align-items:flex-start} + .share-tip .warn-icon{font-size:18px;line-height:1.5;flex-shrink:0} + .share-tip .tip-text{flex:1;min-width:0} + .share-tip strong{font-weight:700} + .warning-box{background:#fff2f0;border:1px solid #ffccc7;border-radius:8px;padding:8px 10px;overflow-x:auto;-webkit-overflow-scrolling:touch} + .warning-item{margin:0;font-size:12px;line-height:1.8;font-weight:700;white-space:nowrap} + .warning-item:nth-child(odd){color:#cf1322} + .warning-item:nth-child(even){color:#d46b08} + .warning-item:last-child{color:#b71c1c;font-size:13px} + .share-qr{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px} + .share-qr canvas{border-radius:8px} + .share-qr .qr-label{font-size:12px;font-weight:600;color:var(--primary)} + .share-qr .qr-sub{font-size:11px;color:var(--text2)} + .share-disclaimer{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:10px;padding:8px 10px;background:#fdf6ec;border-radius:6px;font-size:12px;color:#d46b08;flex-wrap:wrap} + + /* ===== Login Modal ===== */ + .login-form{display:flex;flex-direction:column;gap:12px} + .login-form input{height:40px;border:1px solid var(--border);border-radius:8px;padding:0 12px;font-size:14px;outline:none} + .login-form input:focus{border-color:var(--primary)} + .login-form .login-btn{height:40px;border:none;border-radius:8px;background:var(--primary);color:var(--white);font-size:14px;font-weight:600;cursor:pointer;transition:background .2s} + .login-form .login-btn:active{background:var(--primary-dark)} + .login-form .login-btn:disabled{opacity:.5} + .login-form .login-err{font-size:12px;color:#f56c6c;text-align:center} + + /* ===== Progress Steps ===== */ + .steps{display:flex;flex-direction:column;gap:10px;padding:8px 0} + .step{display:flex;align-items:flex-start;gap:10px;opacity:.4;transition:opacity .3s} + .step.active{opacity:1} + .step.done{opacity:.7} + .step-dot{flex-shrink:0;width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#e4e7ed;color:#909399} + .step.active .step-dot{background:var(--primary);color:#fff;box-shadow:0 0 0 3px rgba(64,158,255,.2)} + .step.done .step-dot{background:#67c23a;color:#fff} + .step-body{flex:1;padding-top:3px;display:flex;align-items:center;gap:8px} + .step-title{font-size:13px;color:var(--text);font-weight:500} + .step-status{font-size:11px;padding:1px 7px;border-radius:10px;white-space:nowrap} + .step-status.doing{background:#ecf5ff;color:var(--primary)} + .step-status.done{background:#f0f9eb;color:#67c23a} + .step-status.wait{background:#f4f4f5;color:#c0c4cc} + .error-alert{padding:12px 16px;background:#fef0f0;border:1px solid #fde2e2;border-radius:8px;display:flex;align-items:center;gap:8px;font-size:14px;color:#f56c6c} + + /* ===== User Badge ===== */ + .user-badge{font-size:12px;color:var(--primary);font-weight:600;white-space:nowrap} + .login-btn-small{height:30px;padding:0 10px;border:none;border-radius:6px;background:var(--primary-light);color:var(--primary);font-size:12px;font-weight:600;cursor:pointer} + .logout-btn-small{height:30px;padding:0 8px;border:none;border-radius:6px;background:transparent;color:var(--text2);font-size:12px;cursor:pointer} diff --git a/packages/frontend/index.html b/packages/frontend/index.html new file mode 100755 index 0000000..df2713e --- /dev/null +++ b/packages/frontend/index.html @@ -0,0 +1,29 @@ + + + + + + CloudSearch - 网盘资源搜索 + + + + +
+ + + diff --git a/packages/frontend/nginx.conf b/packages/frontend/nginx.conf new file mode 100755 index 0000000..0f4afe6 --- /dev/null +++ b/packages/frontend/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # 前端路由(SPA) + location / { + try_files $uri $uri/ /index.html; + } + + # H5 手机版 + location /h5 { + alias /usr/share/nginx/html/h5; + try_files $uri $uri/ /h5/index.html; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://backend:3000; + 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_buffering off; + proxy_read_timeout 120s; + } + + # 静态资源缓存 + location /assets { + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/packages/frontend/package-lock.json b/packages/frontend/package-lock.json new file mode 100755 index 0000000..1151d4e --- /dev/null +++ b/packages/frontend/package-lock.json @@ -0,0 +1,2123 @@ +{ + "name": "cloudsearch-frontend", + "version": "1.1.8", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudsearch-frontend", + "version": "1.1.8", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.0", + "echarts": "^6.0.0", + "element-plus": "^2.8.0", + "pinia": "^2.2.0", + "qrcode": "^1.5.4", + "vue": "^3.5.0", + "vue-echarts": "^8.0.1", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "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==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "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==", + "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==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, + "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==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "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==" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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==", + "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==", + "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==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "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==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "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==", + "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==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-8.0.1.tgz", + "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==", + "peerDependencies": { + "echarts": "^6.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100755 index 0000000..7bd993f --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "cloudsearch-frontend", + "version": "1.1.8", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.0", + "echarts": "^6.0.0", + "element-plus": "^2.8.0", + "pinia": "^2.2.0", + "qrcode": "^1.5.4", + "vue": "^3.5.0", + "vue-echarts": "^8.0.1", + "vue-router": "^4.4.0" + }, + "devDependencies": { + "@types/qrcode": "^1.5.6", + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } +} \ No newline at end of file diff --git a/packages/frontend/public/disclaimer/index.html b/packages/frontend/public/disclaimer/index.html new file mode 100755 index 0000000..6735c1a --- /dev/null +++ b/packages/frontend/public/disclaimer/index.html @@ -0,0 +1,72 @@ + + + + + +免责声明 - 资源分享 + + + +
+

📜 网站免责声明

+ +

一、版权与资源声明

+

本网站(hk-zy.timaa.cn)是一个基于开源项目搭建的非盈利性个人站点,旨在分享与交流技术资源。本网站所有资源均收集整理自互联网,其版权、著作权均归原作者或发行公司所有。本网站不对资源的版权归属进行实质审查,对于任何由资源本身引发的版权争议概不负责。

+ +

二、使用限制与法律责任

+

用户在本网站下载的所有软件、资料等资源,仅供个人学习、研究、技术交流,严禁用于任何商业或非法用途。用户必须在下载后的24小时内,从个人电脑及存储设备中彻底删除相关内容。如用户喜欢该程序或内容,请支持正版,到官方网站购买注册。

+

因用户不当使用(包括但不限于商业使用、非法传播、破解侵权)而引发的一切法律纠纷及后果,由用户自行承担,本网站及网站管理者不承担任何连带责任。

+ +

三、"避风港原则"与侵权处理

+

依据《信息网络传播权保护条例》,本网站仅提供信息存储空间服务或资源索引服务。若用户上传或分享的内容侵犯了您的合法权益,请您立即通过以下联系方式与我们交涉。

+

联系方式: 3337598077@qq.com

+

处理措施: 我们在收到权利人发出的合格通知(包括权属证明和侵权链接)后,将在合理期限内对涉嫌侵权内容进行核实、断开链接或直接删除。

+

唯一目的: 本网站为纯公益、非盈利性分享,绝无意侵害任何第三方权益。若内容涉及侵权,实属无意,请版权方及时通知以便我们处理。

+ +

四、用户行为与网站免责

+

访问者在本网站进行下载、浏览时,须自行承担风险。本网站不保证资源完全无毒、无缺陷或绝对安全,对于因使用本站资源而造成的硬件损坏、数据丢失等损失,本网站不负任何责任。

+

本站内容仅代表资源提供者的个人观点,不代表本站立场。对于任何站点外部链接的真实性、合法性,本站不承担担保责任。

+

凡以任何方式登陆本网站或直接、间接使用本网站资源者,视为自愿接受本网站免责声明的约束。

+ +

五、法律适用

+

本声明未涉及的问题参见国家有关法律法规。当本声明与国家法律法规冲突时,以国家法律法规为准。本网站保留对本声明的最终解释权。

+ + +
+ + diff --git a/packages/frontend/public/h5/index.html b/packages/frontend/public/h5/index.html new file mode 100755 index 0000000..bf89fbb --- /dev/null +++ b/packages/frontend/public/h5/index.html @@ -0,0 +1,923 @@ + + + + + + + + + CloudSearch - 搜索 + + + + + +
+ +
+
+ + +
+
+
+
+
+ + + +
+ + +
+ + + + + + diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue new file mode 100755 index 0000000..82b46a4 --- /dev/null +++ b/packages/frontend/src/App.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/frontend/src/api/index.ts b/packages/frontend/src/api/index.ts new file mode 100755 index 0000000..5747b5b --- /dev/null +++ b/packages/frontend/src/api/index.ts @@ -0,0 +1,433 @@ +import axios from 'axios' +import type { + SearchResponse, + VideoParseResult, + SaveResult, + QueryResponse, + RankingItem, + Promotion, + CloudConfig, + StatsData, +} from '../types' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +// 请求拦截器 — 添加管理员 Token +api.interceptors.request.use((config) => { + const token = localStorage.getItem('admin_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +// 响应拦截器 — 统一错误处理 +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('admin_token') + // Don't redirect if already on the login page or if this was a login attempt itself + if (!window.location.pathname.startsWith('/admin/login') && !err.config?.url?.includes('/admin/login')) { + window.location.href = '/admin/login' + } + } + return Promise.reject(err) + } +) + +// ===== 搜索与解析 ===== +export async function query(q: string, page = 1): Promise { + const { data } = await api.post('/query', { q, page }) + return data +} + +/** + * 流式搜索 — 使用 NDJSON stream,逐条返回验证结果 + * callback 接收五种事件: + * onSearching() - 搜索开始(立即返回) + * onSaved({results, total}) - 本地已保存资源(DB缓存,即时返回) + * onStats({total, channels, content_info, content_tags}) - 统计信息 + * onResult(id, valid, message) - 单条链接验证结果 + * onComplete({results, channels, total, filtered}) - 全部完成 + */ +export async function streamSearch( + q: string, + callbacks: { + onSearching?: () => void + onSaved?: (data: { results: any[]; total: number }) => void + onStats: (stats: any) => void + onResult: (id: string, valid: boolean, message?: string) => void + onComplete: (data: any) => void + onError?: (err: any) => void + } +): Promise { + const token = localStorage.getItem('admin_token') + const headers: Record = { + 'Content-Type': 'application/json', + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + try { + const response = await fetch('/api/query', { + method: 'POST', + headers, + body: JSON.stringify({ q }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const msg = JSON.parse(line) + switch (msg.type) { + case 'searching': + callbacks.onSearching?.() + break + case 'saved': + callbacks.onSaved?.(msg) + break + case 'stats': + callbacks.onStats(msg) + break + case 'result': + callbacks.onResult(msg.id, msg.valid, msg.message) + break + case 'complete': + callbacks.onComplete(msg) + break + } + } catch { + // skip malformed lines + } + } + } + } catch (err: any) { + callbacks.onError?.(err) + } +} + +export async function searchPanSou( + kw: string, + page = 1, + pageSize = 20 +): Promise { + const { data } = await api.get('/search', { + params: { kw, page, page_size: pageSize }, + }) + return data +} + +export async function parseVideo(url: string): Promise { + const { data } = await api.post('/video/parse', { url }) + return data +} + +// ===== 保存与分享 ===== +export async function saveToCloud(params: { + type: 'search' | 'video' + source: any + target_cloud: string +}): Promise { + const { data } = await api.post('/save', params) + return data +} + +export async function saveVideoToCloud(params: { + video_url: string + title: string + target_cloud: string +}): Promise { + const { data } = await api.post('/video/save-to-cloud', params) + return data +} + +// ===== 排行榜 ===== +export async function getRankings(): Promise { + const { data } = await api.get('/rankings') + return data +} + +export async function getHotKeywords(): Promise { + const { data } = await api.get('/rankings/hot') + return data +} + +export async function getCategorizedRankings(): Promise { + const { data } = await api.get('/rankings/categorized') + return data +} + +// ===== 管理员 ===== +export async function adminLogin( + username: string, + password: string +): Promise<{ token: string }> { + const { data } = await api.post('/admin/login', { username, password }) + return data +} + +export async function getMe(): Promise<{ loggedIn: boolean; id?: number; username?: string }> { + const { data } = await api.get('/me') + return data +} + +export async function getCloudConfigs(): Promise { + const { data } = await api.get('/admin/cloud-configs') + return data +} + +export async function saveCloudConfig( + config: CloudConfig & { cookie?: string } +): Promise { + const { data } = await api.post('/admin/cloud-configs', config) + return data +} + +export async function updateCloudConfig( + config: CloudConfig & { cookie?: string } +): Promise { + const { data } = await api.put(`/admin/cloud-configs/${config.id}`, config) + return data +} + +export async function testCloudConnection( + cloudType: string, + cookie?: string, + id?: number +): Promise<{ + success: boolean + message: string + nickname?: string + storage_used?: string + storage_total?: string +}> { + const { data } = await api.post(`/admin/cloud-configs/${cloudType}/test`, { cookie, id }) + return data +} + +export async function dailyCheckIn( + id: number +): Promise<{ + success: boolean + message: string + signedDays?: number +}> { + const { data } = await api.post(`/admin/cloud-configs/${id}/checkin`) + return data +} + +export async function skipCheckin(id: number): Promise { + const { data } = await api.post(`/admin/cloud-configs/${id}/skip-checkin`) + return data.success +} + +export async function checkinAll(): Promise<{ + total: number + results: { id: number; nickname: string; success: boolean; message: string }[] +}> { + const { data } = await api.post('/admin/cloud-configs/checkin-all') + return data +} + +export async function checkinSummary(): Promise<{ + total: number + success: number + failed: number + pending: number + skipped: number +}> { + const { data } = await api.get('/admin/cloud-configs/checkin-summary') + return data +} + +export async function deleteCloudConfig( + id: number +): Promise { + await api.delete(`/admin/cloud-configs/${id}`) +} + +export async function getStats(days?: number): Promise { + const params: Record = {} + if (days) params.days = days + const { data } = await api.get('/admin/stats', { params }) + return data +} + +// ===== 转存日志 ===== +export async function getSaveRecords(page = 1, pageSize = 20, startDate?: string, endDate?: string, status?: string, cloud?: string, keyword?: string): Promise<{ + total: number + records: SaveRecord[] + summary?: { total: number; success: number; failed: number; reused: number } +}> { + const params: Record = { page, pageSize } + if (startDate) params.startDate = startDate + if (endDate) params.endDate = endDate + if (status) params.status = status + if (cloud) params.sourceType = cloud + if (keyword) params.keyword = keyword + const { data } = await api.get('/admin/save-records', { params }) + return data +} + +export interface SaveRecord { + id: number + source_type: string + source_title: string | null + source_url: string + target_cloud: string + share_url: string | null + share_pwd: string | null + file_size: string | null + file_count: number + duration_ms: number + status: string + error_message: string | null + folder_name: string | null + folder_count: number + original_folder_name: string | null + ip_address: string | null + ip_location: string | null + created_at: string +} + +// ===== 系统配置 ===== +export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> { + const { data } = await api.get('/admin/system-configs') + return data +} + +export async function updateSystemConfigs( + entries: { key: string; value: string }[] +): Promise { + await api.put('/admin/system-configs', { entries }) +} + +// ===== 网盘类型开关 ===== +export async function getCloudTypes(): Promise<{ types: { type: string; label: string; icon: string; enabled: boolean }[] }> { + const { data } = await api.get('/admin/cloud-types') + return data +} + +export async function toggleCloudType(type: string, enabled: boolean): Promise { + await api.put('/admin/cloud-types', { type, enabled }) +} + +// ===== 修改密码 ===== +export async function changePassword( + oldPassword: string, + newPassword: string +): Promise<{ success: boolean; message: string }> { + const { data } = await api.post('/admin/change-password', { oldPassword, newPassword }) + return data +} + +export default api +export { query as searchQuery } + +// ===== 系统设置(SettingsManage.vue) ===== +export async function getSettings(): Promise { + const { data } = await api.get('/admin/system-configs') + return data +} +export async function updateSetting(key: string, value: string): Promise { + await api.put('/admin/system-configs', { entries: [{ key, value }] }) +} + +export async function uploadFallbackImage(file: File): Promise<{ success: boolean; url: string; message: string }> { + const form = new FormData() + form.append('image', file) + const { data } = await api.post('/admin/upload-fallback-image', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function uploadLogo(file: File): Promise<{ success: boolean; url: string; message: string }> { + const form = new FormData() + form.append('image', file) + const { data } = await api.post('/admin/upload-logo', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + +export async function getSiteConfig(): Promise<{ site_logo: string; site_name: string; search_fallback_image: string; site_disclaimer: string }> { + const { data } = await api.get('/site-config') + return data +} + +// ===== Redis 连接测试 ===== +export async function testRedisConnection(url: string): Promise<{ ok: boolean; latency: number; info: string }> { + const { data } = await api.post('/admin/test-redis', { url }) + return data +} + +// ===== 外部服务连接测试 ===== +export async function testExternalService(params: { + type: 'pansou' | 'video_parser' | 'tmdb' | 'proxy' | 'ip_geo' + url?: string + token?: string +}): Promise<{ ok: boolean; latency: number; info: string }> { + const { data } = await api.post('/admin/test-external-service', params) + return data +} + +// ===== 数据库状态 ===== +export async function getDbStatus(): Promise<{ + db_size: string + db_path: string + save_records: number + search_stats: number + system_configs: number + cloud_configs: number + content_cache: number + redis_status: string + redis_url: string +}> { + const { data } = await api.get('/admin/db-status') + return data +} + +// ===== 存储清理 ===== +export async function runCleanup(): Promise<{ + success: boolean + files_trashed: number + logs_deleted: number + trash_emptied: boolean + errors: string[] + message: string +}> { + const { data } = await api.post('/admin/cleanup/run') + return data +} + +export async function emptyAllTrash(): Promise<{ + success: boolean + emptied: boolean + errors: string[] + message: string +}> { + const { data } = await api.post('/admin/cleanup/empty-trash') + return data +} diff --git a/packages/frontend/src/api/index.ts.new b/packages/frontend/src/api/index.ts.new new file mode 100644 index 0000000..fddb290 --- /dev/null +++ b/packages/frontend/src/api/index.ts.new @@ -0,0 +1 @@ +// This will be done in chunks via multiple commands to avoid escaping issues diff --git a/packages/frontend/src/components/CloudBadge.vue b/packages/frontend/src/components/CloudBadge.vue new file mode 100755 index 0000000..82b3478 --- /dev/null +++ b/packages/frontend/src/components/CloudBadge.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/packages/frontend/src/components/CloudSelect.vue b/packages/frontend/src/components/CloudSelect.vue new file mode 100755 index 0000000..c6a3ad7 --- /dev/null +++ b/packages/frontend/src/components/CloudSelect.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/frontend/src/components/RankingCard.vue b/packages/frontend/src/components/RankingCard.vue new file mode 100755 index 0000000..d9bcdfc --- /dev/null +++ b/packages/frontend/src/components/RankingCard.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/frontend/src/components/ResultCard.vue b/packages/frontend/src/components/ResultCard.vue new file mode 100755 index 0000000..b8fe1c1 --- /dev/null +++ b/packages/frontend/src/components/ResultCard.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/packages/frontend/src/components/VideoResultCard.vue b/packages/frontend/src/components/VideoResultCard.vue new file mode 100755 index 0000000..be0810c --- /dev/null +++ b/packages/frontend/src/components/VideoResultCard.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/packages/frontend/src/composables/useTrendChart.ts b/packages/frontend/src/composables/useTrendChart.ts new file mode 100644 index 0000000..7583359 --- /dev/null +++ b/packages/frontend/src/composables/useTrendChart.ts @@ -0,0 +1,382 @@ +/** + * useTrendChart + * + * Composable that manages a combined bar+line ECharts trend chart. + * Features: + * - Tree-shakeable ECharts import (core + only used chart types/components) + * - Auto resize via ResizeObserver + * - DataZoom slider for 30+ day ranges + * - Day-over-day delta in tooltips + * - Average markLine + * - Period summary callback + * - Proper cleanup on unmount + */ +import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue' +import { init, use, graphic } from 'echarts/core' +import { BarChart, LineChart } from 'echarts/charts' +import { + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomSliderComponent, + MarkLineComponent, +} from 'echarts/components' +import { CanvasRenderer } from 'echarts/renderers' +import type { ECharts, EChartsCoreOption } from 'echarts/core' + +// Register only the features we actually use +use([ + BarChart, LineChart, + TooltipComponent, GridComponent, LegendComponent, + DataZoomSliderComponent, MarkLineComponent, + CanvasRenderer, +]) + +const { LinearGradient } = graphic + +export interface TrendDataPoint { + date: string + searches: number + saves: number + searchDelta: number + saveDelta: number +} + +export interface TrendSummary { + totalSearches: number + totalSaves: number + avgSearches: number + avgSaves: number + peakDay: string + peakSearches: number + peakSaves: number + dayCount: number +} + +export function useTrendChart( + trendData: Ref, + onSummary?: Ref | ((s: TrendSummary) => void), +) { + const chartRef = ref(null) + let chartInstance: ECharts | null = null + let resizeObserver: ResizeObserver | null = null + + const BAR_WIDTH_PCT = '35%' + const BAR_GAP_PCT = '30%' + + function computeSummary(data: TrendDataPoint[]): TrendSummary { + const totalSearches = data.reduce((s, d) => s + d.searches, 0) + const totalSaves = data.reduce((s, d) => s + d.saves, 0) + const n = data.length || 1 + let peakIdx = 0 + let peakVal = 0 + data.forEach((d, i) => { + const v = d.searches + d.saves + if (v > peakVal) { peakVal = v; peakIdx = i } + }) + return { + totalSearches, + totalSaves, + avgSearches: Math.round(totalSearches / n), + avgSaves: Math.round(totalSaves / n), + peakDay: data[peakIdx]?.date?.slice(5) || '—', + peakSearches: data[peakIdx]?.searches || 0, + peakSaves: data[peakIdx]?.saves || 0, + dayCount: n, + } + } + + function render() { + const el = chartRef.value + const data = trendData.value + if (!el) return + + // Compute summary and emit + const summary = computeSummary(data) + if (typeof onSummary === 'function') { + onSummary(summary) + } else if (onSummary) { + onSummary.value = summary + } + + // Empty state + if (!data.length) { + if (chartInstance) { + chartInstance.dispose() + chartInstance = null + } + el.innerHTML = '
暂无使用数据
' + return + } + + // Handle DOM element recreation (v-if remounts chart div when navigating away & back) + if (!chartInstance || chartInstance.getDom() !== el) { + if (chartInstance) chartInstance.dispose() + chartInstance = init(el) + initResize() + } + + const dates = data.map(d => d.date.slice(5)) // MM-DD + const n = data.length + const maxCount = Math.max(...data.map(d => Math.max(d.searches, d.saves)), 1) + const yMax = Math.ceil(maxCount * 1.35) || 1 + const avgSearches = Math.round(summary.totalSearches / n) + const showDataZoom = n >= 30 + + const option: EChartsCoreOption = { + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + backgroundColor: 'rgba(255,255,255,0.97)', + borderColor: '#e8e8e8', + borderWidth: 1, + borderRadius: 8, + padding: [10, 14], + textStyle: { fontSize: 12, color: '#303133' }, + formatter: (params: any) => { + const idx = params[0]?.dataIndex ?? 0 + const dateLabel = data[idx]?.date || '' + const d = data[idx] + const seen = new Set() + const items = params.filter((p: any) => { + const key = p.seriesName + if (seen.has(key)) return false + seen.add(key) + return true + }) + let html = `
${dateLabel}
` + items.forEach((p: any) => { + const rawVal = p.value + const val = Array.isArray(rawVal) ? rawVal[1] : rawVal + const isBar = p.seriesType === 'bar' + const isSearch = p.seriesName === '搜索' + const delta = isSearch ? d?.searchDelta : d?.saveDelta + const deltaStr = delta !== undefined && delta !== 0 + ? `${delta > 0 ? '↑' : '↓'}${Math.abs(delta)}` + : (delta === 0 ? '→0' : '') + const icon = isBar + ? `` + : `` + html += `
${icon}${p.seriesName}:${val} 次${deltaStr}
` + }) + return html + }, + }, + legend: { + data: ['搜索', '保存'], + bottom: showDataZoom ? 30 : 0, + left: 'center', + itemWidth: 14, + itemHeight: 10, + textStyle: { fontSize: 11, color: '#666' }, + }, + grid: { + left: 8, + right: 12, + top: 28, + bottom: showDataZoom ? 70 : 42, + containLabel: true, + }, + xAxis: { + type: 'category', + data: dates, + axisLabel: { + interval: 0, + fontSize: 11, + color: '#909399', + rotate: n > 15 ? 45 : 0, + }, + axisLine: { lineStyle: { color: '#e8e8e8' } }, + splitLine: { show: false }, + axisTick: { show: false }, + }, + yAxis: { + type: 'value', + name: '次', + nameTextStyle: { fontSize: 10, color: '#909399' }, + min: 0, + max: yMax, + splitNumber: 4, + axisLabel: { + fontSize: 10, color: '#909399' }, + splitLine: { lineStyle: { color: '#f5f5f5', type: 'dashed' } }, + }, + series: [ + // Bar — 搜索 + { + name: '搜索', + type: 'bar', + data: data.map(d => d.searches), + barWidth: BAR_WIDTH_PCT, + barGap: BAR_GAP_PCT, + itemStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#6366f1' }, + { offset: 1, color: '#a5b4fc' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + emphasis: { + itemStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#4f46e5' }, + { offset: 1, color: '#818cf8' }, + ]), + }, + }, + animationDuration: 500, + animationEasing: 'cubicOut', + }, + // Bar — 保存 + { + name: '保存', + type: 'bar', + data: data.map(d => d.saves), + barWidth: BAR_WIDTH_PCT, + barGap: BAR_GAP_PCT, + itemStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#10b981' }, + { offset: 1, color: '#6ee7b7' }, + ]), + borderRadius: [4, 4, 0, 0], + }, + emphasis: { + itemStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: '#059669' }, + { offset: 1, color: '#34d399' }, + ]), + }, + }, + animationDuration: 500, + animationEasing: 'cubicOut', + }, + // Line — 搜索 + { + name: '搜索', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 5, + data: data.map(d => d.searches), + lineStyle: { width: 2.5, color: '#4f46e5' }, + itemStyle: { color: '#4f46e5', borderColor: '#fff', borderWidth: 2 }, + areaStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(99,102,241,0.12)' }, + { offset: 1, color: 'rgba(99,102,241,0.01)' }, + ]), + }, + label: { + show: false, + }, + connectNulls: true, + animationDuration: 700, + animationEasing: 'cubicOut', + z: 3, + markLine: avgSearches > 0 ? { + silent: true, + symbol: 'none', + lineStyle: { color: '#6366f1', type: 'dashed', width: 1, opacity: 0.5 }, + label: { + formatter: `均 ${avgSearches}`, + position: 'insideEndTop', + fontSize: 10, + color: '#6366f1', + }, + data: [{ yAxis: avgSearches, name: '日均搜索' }], + } : undefined, + }, + // Line — 保存 + { + name: '保存', + type: 'line', + smooth: true, + symbol: 'circle', + symbolSize: 5, + data: data.map(d => d.saves), + lineStyle: { width: 2.5, color: '#059669' }, + itemStyle: { color: '#059669', borderColor: '#fff', borderWidth: 2 }, + areaStyle: { + color: new LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: 'rgba(16,185,129,0.12)' }, + { offset: 1, color: 'rgba(16,185,129,0.01)' }, + ]), + }, + label: { + show: false, + }, + connectNulls: true, + animationDuration: 700, + animationEasing: 'cubicOut', + z: 3, + markLine: avgSearches > 0 ? { + silent: true, + symbol: 'none', + lineStyle: { color: '#10b981', type: 'dashed', width: 1, opacity: 0.5 }, + label: { + formatter: `均 ${Math.round(summary.totalSaves / n)}`, + position: 'insideEndTop', + fontSize: 10, + color: '#10b981', + }, + data: [{ yAxis: Math.round(summary.totalSaves / n), name: '日均保存' }], + } : undefined, + }, + ], + // DataZoom slider for 30+ day views + ...(showDataZoom ? { + dataZoom: [{ + type: 'slider', + bottom: 6, + height: 20, + start: 0, + end: 100, + borderColor: '#e8e8e8', + fillerColor: 'rgba(99,102,241,0.08)', + handleStyle: { color: '#6366f1', borderColor: '#6366f1' }, + textStyle: { fontSize: 10, color: '#909399' }, + }], + } : {}), + } + + chartInstance.setOption(option, true) + } + + // Auto-render when data changes + watch( + trendData, + () => { + nextTick(() => render()) + }, + { deep: true }, + ) + + // Setup ResizeObserver for responsive chart + function initResize() { + if (!chartRef.value) return + if (resizeObserver) { + resizeObserver.disconnect() + resizeObserver = null + } + resizeObserver = new ResizeObserver(() => { + chartInstance?.resize() + }) + resizeObserver.observe(chartRef.value) + } + + // Cleanup + onUnmounted(() => { + resizeObserver?.disconnect() + resizeObserver = null + chartInstance?.dispose() + chartInstance = null + }) + + return { + chartRef, + render, + initResize, + } +} diff --git a/packages/frontend/src/env.d.ts b/packages/frontend/src/env.d.ts new file mode 100755 index 0000000..323c78a --- /dev/null +++ b/packages/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts new file mode 100755 index 0000000..b0d5cff --- /dev/null +++ b/packages/frontend/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' +import './styles/global.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) +app.mount('#app') diff --git a/packages/frontend/src/pages/HomePage.vue b/packages/frontend/src/pages/HomePage.vue new file mode 100755 index 0000000..f568773 --- /dev/null +++ b/packages/frontend/src/pages/HomePage.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/packages/frontend/src/pages/ResultDetail.vue b/packages/frontend/src/pages/ResultDetail.vue new file mode 100755 index 0000000..8230b28 --- /dev/null +++ b/packages/frontend/src/pages/ResultDetail.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/packages/frontend/src/pages/SearchResult.vue b/packages/frontend/src/pages/SearchResult.vue new file mode 100755 index 0000000..f2f0965 --- /dev/null +++ b/packages/frontend/src/pages/SearchResult.vue @@ -0,0 +1,2199 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/AdminDashboard.vue b/packages/frontend/src/pages/admin/AdminDashboard.vue new file mode 100644 index 0000000..f4a9f68 --- /dev/null +++ b/packages/frontend/src/pages/admin/AdminDashboard.vue @@ -0,0 +1,1071 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/AdminLayout.vue b/packages/frontend/src/pages/admin/AdminLayout.vue new file mode 100644 index 0000000..4b1e263 --- /dev/null +++ b/packages/frontend/src/pages/admin/AdminLayout.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/AdminLogin.vue b/packages/frontend/src/pages/admin/AdminLogin.vue new file mode 100755 index 0000000..f5c7ec9 --- /dev/null +++ b/packages/frontend/src/pages/admin/AdminLogin.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/Cleanup.vue b/packages/frontend/src/pages/admin/Cleanup.vue new file mode 100644 index 0000000..0c865ce --- /dev/null +++ b/packages/frontend/src/pages/admin/Cleanup.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/CloudConfig.vue b/packages/frontend/src/pages/admin/CloudConfig.vue new file mode 100755 index 0000000..2e97f25 --- /dev/null +++ b/packages/frontend/src/pages/admin/CloudConfig.vue @@ -0,0 +1,701 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/SaveRecords.vue b/packages/frontend/src/pages/admin/SaveRecords.vue new file mode 100755 index 0000000..52c3b66 --- /dev/null +++ b/packages/frontend/src/pages/admin/SaveRecords.vue @@ -0,0 +1,774 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/StatsView.vue b/packages/frontend/src/pages/admin/StatsView.vue new file mode 100755 index 0000000..16a13e0 --- /dev/null +++ b/packages/frontend/src/pages/admin/StatsView.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/SystemConfig.vue b/packages/frontend/src/pages/admin/SystemConfig.vue new file mode 100755 index 0000000..d492699 --- /dev/null +++ b/packages/frontend/src/pages/admin/SystemConfig.vue @@ -0,0 +1,1344 @@ + + + + + diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts new file mode 100755 index 0000000..dff9429 --- /dev/null +++ b/packages/frontend/src/router.ts @@ -0,0 +1,76 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + name: 'home', + component: () => import('./pages/HomePage.vue'), + }, + { + path: '/search', + name: 'search', + component: () => import('./pages/SearchResult.vue'), + }, + { + path: '/result/:id', + name: 'result-detail', + component: () => import('./pages/ResultDetail.vue'), + }, + { + path: '/admin/login', + name: 'admin-login', + component: () => import('./pages/admin/AdminLogin.vue'), + }, + { + path: '/admin', + component: () => import('./pages/admin/AdminLayout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + redirect: '/admin/dashboard', + }, + { + path: 'dashboard', + name: 'admin-dashboard', + component: () => import('./pages/admin/AdminDashboard.vue'), + }, + { + path: 'cloud-configs', + name: 'admin-cloud-configs', + component: () => import('./pages/admin/CloudConfig.vue'), + }, + { + path: 'cleanup', + name: 'admin-cleanup', + component: () => import('./pages/admin/Cleanup.vue'), + }, + { + path: 'system', + name: 'admin-system', + component: () => import('./pages/admin/SystemConfig.vue'), + }, + { + path: 'save-records', + name: 'admin-save-records', + component: () => import('./pages/admin/SaveRecords.vue'), + }, + ], + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, _from, next) => { + const token = localStorage.getItem('admin_token') + if (to.meta.requiresAuth && !token) { + next('/admin/login') + } else { + next() + } +}) + +export default router diff --git a/packages/frontend/src/styles/global.css b/packages/frontend/src/styles/global.css new file mode 100755 index 0000000..7fd51ea --- /dev/null +++ b/packages/frontend/src/styles/global.css @@ -0,0 +1,27 @@ +/* 全局样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + color: #333; + background: #f5f7fa; +} + +:root { + --primary-color: #409eff; + --primary-dark: #337ecc; + --text-secondary: #909399; + --border-color: #e4e7ed; + --bg-white: #ffffff; + --shadow-card: 0 2px 12px rgba(0, 0, 0, 0.08); + --radius-card: 12px; +} + +a { + text-decoration: none; + color: var(--primary-color); +} diff --git a/packages/frontend/src/types/index.ts b/packages/frontend/src/types/index.ts new file mode 100755 index 0000000..e1b5239 --- /dev/null +++ b/packages/frontend/src/types/index.ts @@ -0,0 +1,199 @@ +/* ===== 搜索结果类型 ===== */ +export interface SearchResult { + id: string + title: string + description?: string + cover?: string + file_size?: string + update_time?: string + datetime?: string + cloud_type: CloudType + share_url?: string + source?: string + password?: string + file_id?: string + valid?: boolean +} + +export type CloudType = + | 'quark' | 'baidu' | 'aliyun' | '115' + | 'tianyi' | '123pan' | 'uc' | 'xunlei' + | 'pikpak' | 'magnet' | 'ed2k' | 'others' + +export const CLOUD_LABELS: Record = { + quark: '夸克网盘', + baidu: '百度网盘', + aliyun: '阿里云盘', + '115': '115网盘', + tianyi: '天翼云盘', + '123pan': '123云盘', + uc: 'UC网盘', + xunlei: '迅雷云盘', + pikpak: 'PikPak', + magnet: '磁力链接', + ed2k: '电驴链接', + others: '其他', +} + +export const CLOUD_COLORS: Record = { + quark: '#07c160', + baidu: '#4e6ef2', + aliyun: '#ff6a00', + '115': '#9b59b6', + tianyi: '#00a1d6', + '123pan': '#e74c3c', + uc: '#f39c12', + xunlei: '#2ecc71', + pikpak: '#8e44ad', + magnet: '#95a5a6', + ed2k: '#7f8c8d', + others: '#95a5a6', +} + +/** + * 网盘图标映射 — 全部使用内联 SVG data URI,无需外部文件 + * 每个网盘类型使用其品牌色圆角底 + 首字母/中文标识 + */ +function makeSvgIcon(bg: string, letter: string): string { + const c = encodeURIComponent(bg) + const l = encodeURIComponent(letter) + return `data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22${c}%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2213%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3E${l}%3C%2Ftext%3E%3C%2Fsvg%3E` +} +const ICON_SVGS: Record = { + magnet: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%236366F1%22%2F%3E%3Cpath%20d%3D%22M7%2016l5-5m-5%200l5%205m5-5l-5-5m5%200l-5%205%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20fill%3D%22none%22%2F%3E%3Ccircle%20cx%3D%2212%22%20cy%3D%2211%22%20r%3D%221%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E', + ed2k: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%238B4513%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20font-size%3D%2211%22%20font-weight%3D%22bold%22%20fill%3D%22%23fff%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2Csans-serif%22%3EeD%3C%2Ftext%3E%3C%2Fsvg%3E', + others: 'data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20width%3D%2224%22%20height%3D%2224%22%20rx%3D%224%22%20fill%3D%22%239CA3AF%22%2F%3E%3Cpath%20d%3D%22M6%2013c0-2.8%202.2-5%205-5a5%205%200%200%201%204.5%202.7A4%204%200%200%201%2020%2014a4%204%200%200%201-3%203.9h-8A4%204%200%200%201%206%2013z%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%221.5%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E', +} +export const CLOUD_ICONS: Record = { + baidu: makeSvgIcon('#4e6ef2', '百'), + aliyun: makeSvgIcon('#ff6a00', '阿'), + quark: makeSvgIcon('#07c160', '夸'), + '115': makeSvgIcon('#9b59b6', '1'), + tianyi: makeSvgIcon('#00a1d6', '天'), + '123pan': makeSvgIcon('#e74c3c', '1'), + uc: makeSvgIcon('#f39c12', 'U'), + xunlei: makeSvgIcon('#2ecc71', '迅'), + pikpak: makeSvgIcon('#8e44ad', 'P'), + magnet: ICON_SVGS.magnet, + ed2k: ICON_SVGS.ed2k, + others: ICON_SVGS.others, +} + +/* ===== 视频解析类型 ===== */ +export interface VideoParseResult { + title: string + cover: string + video_url: string + author?: string + platform: string + description?: string +} + +/* ===== 保存结果类型 ===== */ +export interface SaveResult { + success: boolean + share_url: string + share_pwd?: string + expire_at?: string + file_name: string + file_size: string + message?: string +} + +/* ===== 排行榜类型 ===== */ +export interface RankingItem { + id: number + title: string + search_count: number + cloud_type: CloudType + cover?: string +} + +/* ===== 推广类型 ===== */ +export interface Promotion { + id?: number + title: string + description: string + image_url: string + link_url: string + position: 'home_banner' | 'search_top' | 'sidebar' + sort_order: number + active: boolean + click_count?: number + start_time?: string + end_time?: string +} + +/* ===== 网盘配置类型 ===== */ +export interface CloudConfig { + id?: number + cloud_type: CloudType + nickname?: string + is_active: boolean + cookie?: string + cookie_preview?: string + storage_used?: string + storage_total?: string + checkin_status?: 'none' | 'success' | 'failed' | 'pending' | 'skipped' + last_checkin_at?: string + checkin_message?: string + consecutive_failures?: number + last_used_at?: string + total_saves?: number + verification_status?: 'untested' | 'valid' | 'invalid' + last_verified_at?: string + promotion_account?: string + is_transfer_enabled?: number +} + +/* ===== API 响应类型 ===== */ +export interface ApiResponse { + success: boolean + data?: T + message?: string + error?: string +} + +export interface SearchResponse { + results: SearchResult[] + total: number + filtered: number + channels?: ChannelGroup[] + link_validation?: boolean +} + +export interface ChannelGroup { + cloud_type: string + label: string + color: string + count: number + items: any[] + newestTime?: string +} + +export interface StatsData { + todaySearches: number + todaySaves: number + monthSearches: number + monthSaves: number + totalSearches: number + totalSaves: number + hotKeywords: { keyword: string; count: number }[] + trendTrend: { date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }[] + cloudUsage: { cloudType: string; nickname: string; storageUsed: string; storageTotal: string; isActive: boolean }[] + topIps: { ip: string; ip_location: string | null; count: number }[] + provinceRankings: { province: string; count: number }[] +} + +/* ===== 意图识别结果 ===== */ +export type IntentType = 'SEARCH' | 'VIDEO_PARSE' | 'CLOUD_SAVE' + +export interface QueryResponse { + intent: IntentType + results: SearchResult[] | VideoParseResult[] + channels?: ChannelGroup[] + total: number + filtered?: number + platform?: string + link_validation?: boolean +} diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json new file mode 100755 index 0000000..32ce0de --- /dev/null +++ b/packages/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/frontend/tsconfig.node.json b/packages/frontend/tsconfig.node.json new file mode 100755 index 0000000..42872c5 --- /dev/null +++ b/packages/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts new file mode 100755 index 0000000..c670e4b --- /dev/null +++ b/packages/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cd62aa1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "packages"] +}