From 4a1f41609d9ff53c57a1060adea2ea06e76d5d43 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 07:08:46 +0800 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E7=9A=84=20Express=20+=20SQLite=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 Express + SQLite 后端,支持仓库、发布、分类、配置的 CRUD 路由 - 添加 GitHub/AI/WebDAV 代理路由,解决浏览器 CORS 问题 - 添加 AES-256 加密存储,保护 API 密钥安全 - 添加跨设备数据同步功能 - 添加前端 BackendAdapter,支持自动发现和认证 - 添加设置面板后端服务器配置区域(连接测试、同步控制) - 添加双语错误提示(51 个错误码,中英文翻译) - 添加 Docker Compose 部署方案(前端 + 后端 + nginx) - 添加服务端测试(加密: 7 个, 代理: 13 个) - 添加开发脚本(dev:server, dev:all, build:server, build:all) - 更新 README.md 和 README_zh.md,补充后端文档和语言切换 --- .gitignore | 10 +- README.md | 102 +- README_zh.md | 175 +- docker-compose.yml | 23 + nginx.conf | 13 + package-lock.json | 425 ++- package.json | 9 +- server/.gitignore | 4 + server/Dockerfile | 16 + server/data/.gitkeep | 0 server/package-lock.json | 4146 +++++++++++++++++++++++++ server/package.json | 31 + server/postcss.config.js | 3 + server/src/config.ts | 50 + server/src/db/connection.ts | 29 + server/src/db/migrations.ts | 43 + server/src/db/schema.ts | 93 + server/src/index.ts | 82 + server/src/middleware/auth.ts | 42 + server/src/middleware/errorHandler.ts | 18 + server/src/routes/categories.ts | 194 ++ server/src/routes/configs.ts | 295 ++ server/src/routes/health.ts | 13 + server/src/routes/proxy.ts | 190 ++ server/src/routes/releases.ts | 166 + server/src/routes/repositories.ts | 184 ++ server/src/routes/sync.ts | 250 ++ server/src/services/crypto.ts | 46 + server/src/services/proxyService.ts | 63 + server/tests/routes/proxy.test.ts | 283 ++ server/tests/services/crypto.test.ts | 66 + server/tsconfig.json | 19 + server/vitest.config.ts | 11 + src/App.tsx | 6 + src/components/SettingsPanel.tsx | 198 +- src/services/backendAdapter.ts | 273 ++ src/store/useAppStore.ts | 10 +- src/types/index.ts | 3 + src/utils/backendErrors.ts | 77 + 39 files changed, 7589 insertions(+), 72 deletions(-) create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/data/.gitkeep create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/postcss.config.js create mode 100644 server/src/config.ts create mode 100644 server/src/db/connection.ts create mode 100644 server/src/db/migrations.ts create mode 100644 server/src/db/schema.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middleware/auth.ts create mode 100644 server/src/middleware/errorHandler.ts create mode 100644 server/src/routes/categories.ts create mode 100644 server/src/routes/configs.ts create mode 100644 server/src/routes/health.ts create mode 100644 server/src/routes/proxy.ts create mode 100644 server/src/routes/releases.ts create mode 100644 server/src/routes/repositories.ts create mode 100644 server/src/routes/sync.ts create mode 100644 server/src/services/crypto.ts create mode 100644 server/src/services/proxyService.ts create mode 100644 server/tests/routes/proxy.test.ts create mode 100644 server/tests/services/crypto.test.ts create mode 100644 server/tsconfig.json create mode 100644 server/vitest.config.ts create mode 100644 src/services/backendAdapter.ts create mode 100644 src/utils/backendErrors.ts diff --git a/.gitignore b/.gitignore index 6a60304..2dd4e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,12 @@ dist-ssr *.sw? .env release -electron \ No newline at end of file +electron + +# Backend server data +server/data/*.db +server/data/.encryption-key +server/dist/ + + # Orchestrator internal files +.sisyphus/ \ No newline at end of file diff --git a/README.md b/README.md index 79bd711..b10b5af 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ An app for managing github starred repositories. +**[中文文档](README_zh.md)** | English + + ## ✨ Features Tired of starring everything and finding nothing? GitHub Stars Manager automatically syncs your starred repos, uses AI to summarize and categorize them, and lets you find anything with semantic search. Track releases, filter assets, and one‑click download—smarter than manual tags, simpler than GitHub. @@ -25,6 +28,7 @@ Tired of starring everything and finding nothing? GitHub Stars Manager automatic - Smart filters: match assets by keywords (e.g., dmg/mac/arm64/aarch64) - Bilingual wiki jump: deepwiki (EN) or zread (ZH) based on language - Packaged client: no environment setup required +- Optional backend: cross-device sync, CORS-free API proxying, and encrypted token storage via Express + SQLite ### Starred Repo Manager @@ -45,6 +49,13 @@ Use your own AI model API that supports OpenAI-compatible interfaces. ![SCR-20250629-qldc](upload/SCR-20250629-qldc.png) +## 🛠 Tech Stack + +- **Frontend**: React 18 + TypeScript + Tailwind CSS +- **State Management**: Zustand +- **Icons**: Lucide React + Font Awesome +- **Build Tool**: Vite + ## 👋🏻 How to Use ### 💻 Desktop Client (Recommended) @@ -58,24 +69,105 @@ https://github.com/AmintaCCCP/GithubStarsManager/releases 2. Navigate to the directory, and open a Terminal window at the downloaded folder. 3. Run `npm install` to install dependencies and `npm run dev` to build -> 💡 When running the project locally using `npm run dev`, calls to AI services and WebDAV may fail due to CORS restrictions. To avoid this issue, use the prebuilt client application or build the client yourself. +> 💡 When running the project locally using `npm run dev`, calls to AI services and WebDAV may fail due to CORS restrictions. To avoid this issue, use the prebuilt client application or build the client yourself. Alternatively, run the backend server (`cd server && npm run dev`) to proxy API calls and avoid CORS entirely. ### 🐳 Run With Docker You can also run this application using Docker. See [DOCKER.md](DOCKER.md) for detailed instructions on how to build and deploy using Docker. The Docker setup handles CORS properly and allows you to configure any AI or WebDAV service URLs directly in the application. +### 🖥️ Backend Server (Optional) + +The app works fully without a backend (pure frontend, localStorage). An optional Express + SQLite backend adds: +- **Cross-device sync**: Share data between browsers/devices +- **CORS-free proxying**: AI and WebDAV calls go through the server, avoiding browser CORS issues +- **Token security**: API keys stored encrypted on server, never exposed to browser network tab + +#### Quick Start (Docker — recommended) +```bash +docker-compose up --build +``` +Frontend on port 8080, backend on port 3000. Data persisted in a Docker volume. + +#### Manual Setup +```bash +cd server +npm install +npm run dev +``` + +#### Environment Variables +| Variable | Required | Description | +|----------|----------|-------------| +| `API_SECRET` | No | Bearer token for API authentication. If unset, auth is disabled. | +| `ENCRYPTION_KEY` | No | AES-256 key for encrypting stored secrets. Auto-generated if unset. | +| `PORT` | No | Server port (default: 3000) | + +#### Connecting Frontend to Backend +1. Open Settings panel in the app +2. Find "Backend Server" section +3. Enter API Secret (if configured) +4. Click "Test Connection" — green indicator means connected +5. Use "Sync to Backend" / "Sync from Backend" to transfer data + +## 🤖 AI Service Configuration + +The app supports multiple AI providers. Configure yours in the Settings panel: + +- **OpenAI**: GPT-3.5 / GPT-4 +- **Anthropic**: Claude +- **Ollama**: local models with no API key needed +- **Any OpenAI-compatible API**: custom endpoint + key + +Steps: open Settings, add an AI config, enter your endpoint and key, pick a model, then test the connection. -## Who it’s for +## 💾 WebDAV Backup Configuration + +Back up and sync your data via any standard WebDAV service: + +- **Jianguoyun (坚果云)**: recommended for users in China +- **Nextcloud**: self-hosted cloud storage +- **ownCloud**: enterprise-grade option +- **Any standard WebDAV server** + +Steps: open Settings, add a WebDAV config, enter the server URL, username, password, and path, test the connection, then enable auto-backup. + +## 🚀 Deployment + +The build output is a static site, so it deploys anywhere static hosting is supported: + +- **Netlify**: connect your fork, set build command `npm run build`, publish directory `dist` +- **Vercel**: same as Netlify — import repo, build runs automatically +- **GitHub Pages**: push the `dist` folder to a `gh-pages` branch +- **Cloudflare Pages**: connect repo, build command `npm run build`, output `dist` +- **Self-hosted**: serve the `dist` folder with any HTTP server (nginx, Caddy, etc.) + +For Docker deployment see the [Backend Server](#️-backend-server-optional) section above. + +## Who it's for Developers with hundreds/thousands of stars People who systematically track releases -“Lazy-efficient” users who don’t want manual tagging +"Lazy-efficient" users who don't want manual tagging ## Additional Notes -1. There is no backend for this app, so save your important data on your own. +1. The backend is optional but recommended for web deployment. Without it, all data is stored in your browser's localStorage — back up important data regularly. 2. I can't write code, this app is entirely written by the AI, mainly for my personal requirment. If you have a new feature or meet a bug, I can only try to do it, but I can't guarantee it, because it depends on the AI to do it successfully.😹 +## 🤝 Contributing + +Contributions are welcome! + +1. Fork the project +2. Create a feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📄 License + +MIT — see [LICENSE](LICENSE) for details. + ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=AmintaCCCP/GithubStarsManager&type=Date)](https://www.star-history.com/#AmintaCCCP/GithubStarsManager&Date) \ No newline at end of file +[![Star History Chart](https://api.star-history.com/svg?repos=AmintaCCCP/GithubStarsManager&type=Date)](https://www.star-history.com/#AmintaCCCP/GithubStarsManager&Date) diff --git a/README_zh.md b/README_zh.md index b113f03..b7abc24 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,102 +1,112 @@ -# GitHub Stars Manager +
+ +![Logo](upload/logo.png) + +# GithubStarsManager + +![100% 本地数据](https://img.shields.io/badge/数据存储-100%25本地-success?style=flat&logo=database&logoColor=white) ![AI 支持](https://img.shields.io/badge/AI-支持多模型-blue?style=flat&logo=openai&logoColor=white) ![全平台](https://img.shields.io/badge/平台-Windows%20%7C%20macOS%20%7C%20Linux-purple?style=flat&logo=electron&logoColor=white) 一个基于AI的GitHub星标仓库管理工具,帮助您更好地组织和管理您的GitHub星标项目。 -An AI-powered GitHub starred repositories management tool to help you better organize and manage your GitHub starred projects. +GithubStarsManager - AI organizes GitHub stars for easy find | Product Hunt -## 功能特性 / Features +
-### 🔐 多种登录方式 / Multiple Login Methods -- **GitHub OAuth**: 安全便捷的一键授权登录 -- **Personal Access Token**: 适合高级用户的token登录方式 +中文 | **[English](README.md)** -### 🤖 AI智能分析 / AI-Powered Analysis +## 功能特性 + +### 🤖 AI智能分析 - 自动分析仓库内容并生成中文摘要 - 智能提取项目标签和支持平台 - 基于AI的自然语言搜索功能 -### 📂 智能分类管理 / Smart Category Management +### 📂 智能分类管理 - 预设14个常用应用分类 - 支持自定义分类创建和管理 - 基于AI标签的自动分类匹配 -### 🔔 Release订阅追踪 / Release Subscription & Tracking +### ⭐ 星标仓库管理 + +自动拉取您GitHub账户下的星标仓库,通过AI自动分析并生成仓库描述、标签和分类。支持过滤、关键词搜索,快速定位任意仓库。 + +![SCR-20250629-qkjk](upload/repo.jpg) + +### 🔔 Release订阅追踪 - 订阅感兴趣仓库的Release更新 - 智能解析下载链接和支持平台 - Release时间线视图和已读状态管理 -### 🔍 强大的搜索功能 / Powerful Search Features +订阅星标仓库的发布通知,文件发布后即可快速查看和下载。 + +![SCR-20250629-qkea](upload/release.jpg) + +### 🔍 强大的搜索功能 - AI驱动的自然语言搜索 - 多维度过滤(语言、平台、标签、状态) - 高级搜索和排序选项 -### 💾 数据备份同步 / Data Backup & Sync +### 💾 数据备份同步 - WebDAV云存储备份支持 - 跨设备数据同步 - 本地数据持久化存储 -### 🎨 现代化界面 / Modern UI +### 🎨 现代化界面 - 响应式设计,支持移动端 - 深色/浅色主题切换 - 中英文双语支持 -## 技术栈 / Tech Stack +### 🖥️ 可选后端服务 +- 可选的 Express + SQLite 后端,支持跨设备数据同步 +- AI 和 WebDAV 请求通过服务器代理,避免浏览器 CORS 限制 +- API 密钥加密存储在服务器,增强安全性 + +### 🤖 自定义AI模型 + +使用您自己的AI模型API,支持OpenAI兼容接口。 -- **Frontend**: React 18 + TypeScript + Tailwind CSS -- **State Management**: Zustand -- **Icons**: Lucide React + Font Awesome -- **Build Tool**: Vite -- **Deployment**: Netlify +![SCR-20250629-qldc](upload/SCR-20250629-qldc.png) -## 快速开始 / Quick Start +## 技术栈 -### 1. 克隆项目 / Clone Repository +- **前端**: React 18 + TypeScript + Tailwind CSS +- **状态管理**: Zustand +- **图标**: Lucide React + Font Awesome +- **构建工具**: Vite +- **部署**: Netlify + +## 💻 桌面客户端(推荐) + +直接下载桌面客户端,无需配置环境: + +https://github.com/AmintaCCCP/GithubStarsManager/releases + +## 快速开始 + +### 1. 克隆项目 ```bash git clone https://github.com/AmintaCCCP/GithubStarsManager.git cd GithubStarsManager ``` -### 2. 安装依赖 / Install Dependencies +### 2. 安装依赖 ```bash npm install ``` -### 3. 配置环境变量 / Configure Environment Variables - -创建 `.env` 文件并配置以下变量: - -```env -# GitHub OAuth App配置 (可选) -REACT_APP_GITHUB_CLIENT_ID=your_github_client_id -REACT_APP_GITHUB_CLIENT_SECRET=your_github_client_secret -``` - -### 4. 启动开发服务器 / Start Development Server +### 3. 启动开发服务器 ```bash npm run dev ``` -### 5. 构建生产版本 / Build for Production +> 💡 本地使用 `npm run dev` 运行项目时,AI 服务和 WebDAV 的调用可能因浏览器 CORS 限制而失败。建议使用预编译客户端,或启动后端服务器(`cd server && npm run dev`)代理 API 请求以完全避免 CORS 问题。 + +### 4. 构建生产版本 ```bash npm run build ``` -## GitHub OAuth配置 / GitHub OAuth Setup - -如果要使用OAuth登录功能,需要在GitHub上创建OAuth App: - -1. 访问 [GitHub Developer Settings](https://github.com/settings/developers) -2. 点击 "New OAuth App" -3. 填写应用信息: - - **Application name**: GitHub Stars Manager - - **Homepage URL**: `https://your-domain.com` - - **Authorization callback URL**: `https://your-domain.com/auth/callback` -4. 获取 Client ID 和 Client Secret -5. 将它们配置到环境变量中 - -**注意**: 出于安全考虑,在生产环境中应该通过后端服务器处理OAuth token交换,而不是在前端直接使用Client Secret。 - -## AI服务配置 / AI Service Configuration +## 🤖 AI服务配置 应用支持多种AI服务提供商: @@ -111,7 +121,7 @@ npm run build 3. 选择模型 4. 测试连接 -## WebDAV备份配置 / WebDAV Backup Configuration +## 💾 WebDAV备份配置 支持多种WebDAV服务: - **坚果云**: 国内用户推荐 @@ -125,7 +135,7 @@ npm run build 3. 测试连接 4. 启用自动备份 -## 部署 / Deployment +## 🚀 部署 ### Netlify部署 1. Fork本项目到您的GitHub账户 @@ -133,8 +143,7 @@ npm run build 3. 配置构建设置: - Build command: `npm run build` - Publish directory: `dist` -4. 配置环境变量(如果使用OAuth) -5. 部署 +4. 部署 ### 其他平台 项目构建后生成静态文件,可以部署到任何静态网站托管服务: @@ -146,7 +155,53 @@ npm run build ### Docker 部署 您也可以使用 Docker 来运行此应用程序。请参阅 [DOCKER.md](DOCKER.md) 获取详细的构建和部署说明。Docker 设置正确处理了 CORS,并允许您直接在应用程序中配置任何 AI 或 WebDAV 服务 URL。 -## 贡献 / Contributing +### 🖥️ 后端服务器(可选) + +应用在没有后端的情况下也能完整运行(纯前端,使用 localStorage)。可选的 Express + SQLite 后端提供以下额外功能: + +- **跨设备同步**: 在不同浏览器和设备间共享数据 +- **无 CORS 代理**: AI 和 WebDAV 请求通过服务器转发,避免浏览器 CORS 限制 +- **令牌安全**: API 密钥加密存储在服务器,不会暴露在浏览器网络请求中 + +#### 快速启动(推荐使用 Docker) +```bash +docker-compose up --build +``` +前端运行在 8080 端口,后端运行在 3000 端口。数据持久化存储在 Docker 卷中。 + +#### 手动启动 +```bash +cd server +npm install +npm run dev +``` + +#### 环境变量 +| 变量 | 必填 | 说明 | +|----------|----------|-------------| +| `API_SECRET` | 否 | API 认证令牌。未设置时禁用认证。 | +| `ENCRYPTION_KEY` | 否 | 用于加密存储密钥的 AES-256 密钥。未设置时自动生成。 | +| `PORT` | 否 | 服务器端口(默认:3000) | + +#### 前端连接后端 +1. 打开应用中的设置面板 +2. 找到「后端服务器」部分 +3. 输入 API Secret(如已配置) +4. 点击「测试连接」,绿色指示灯表示连接成功 +5. 使用「同步到后端」/「从后端同步」来传输数据 + +## 目标用户 + +- 拥有数百甚至数千星标的开发者 +- 系统性追踪软件发布的用户 +- 不想手动打标签的「懒效率」用户 + +## 补充说明 + +1. 后端为可选项,但对于网页部署推荐启用。不启用时,所有数据存储在浏览器 localStorage 中,请定期备份重要数据。 +2. 我不会写代码,这个应用完全由AI编写,主要满足我个人需求。如果您有新功能需求或遇到Bug,我只能尽力尝试,但无法保证成功,因为这取决于AI能否完成。😹 + +## 贡献 欢迎提交Issue和Pull Request! @@ -156,18 +211,22 @@ npm run build 4. 推送到分支 (`git push origin feature/AmazingFeature`) 5. 开启Pull Request -## 许可证 / License +## 许可证 本项目采用MIT许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 -## 支持 / Support +## 支持 如果您觉得这个项目有用,请给它一个⭐️! 如有问题或建议,请提交Issue或联系作者。 +## 星标历史 + +[![Star History Chart](https://api.star-history.com/svg?repos=AmintaCCCP/GithubStarsManager&type=Date)](https://www.star-history.com/#AmintaCCCP/GithubStarsManager&Date) + --- -**Live Demo**: [https://soft-stroopwafel-2b73d1.netlify.app](https://soft-stroopwafel-2b73d1.netlify.app) +**在线演示**: [https://soft-stroopwafel-2b73d1.netlify.app](https://soft-stroopwafel-2b73d1.netlify.app) -**GitHub Repository**: [https://github.com/AmintaCCCP/GithubStarsManager](https://github.com/AmintaCCCP/GithubStarsManager) \ No newline at end of file +**GitHub 仓库**: [https://github.com/AmintaCCCP/GithubStarsManager](https://github.com/AmintaCCCP/GithubStarsManager) diff --git a/docker-compose.yml b/docker-compose.yml index 1d964c8..4d130b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,28 @@ version: '3.8' +services: + frontend: + build: . + ports: + - "8080:80" + depends_on: + - backend + restart: unless-stopped + + backend: + build: ./server + ports: + - "3000:3000" + environment: + - API_SECRET=${API_SECRET:-} + - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} + volumes: + - backend-data:/app/data + restart: unless-stopped + +volumes: + backend-data: + services: github-stars-manager: build: . diff --git a/nginx.conf b/nginx.conf index 76c3173..084e279 100644 --- a/nginx.conf +++ b/nginx.conf @@ -30,6 +30,19 @@ http { server { listen 80; server_name localhost; + # Backend API proxy + location /api/ { + proxy_pass http://backend:3000/api/; + proxy_http_version 1.1; + 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_read_timeout 120s; + proxy_send_timeout 120s; + client_max_body_size 50m; + } + # Handle preflight requests for CORS location / { diff --git a/package-lock.json b/package-lock.json index b973ef2..88f1197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "github-stars-manager", - "version": "0.1.3", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-stars-manager", - "version": "0.1.3", + "version": "0.1.8", "dependencies": { "date-fns": "^3.3.1", "lucide-react": "^0.344.0", @@ -21,6 +21,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.18", + "concurrently": "^8.2.0", "eslint": "^9.9.1", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.11", @@ -294,6 +295,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz", @@ -1832,6 +1843,120 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1862,6 +1987,143 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/concurrently/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/concurrently/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2444,6 +2706,16 @@ "node": ">=6.9.0" } }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2783,6 +3055,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3356,6 +3635,16 @@ "node": ">=8.10.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3450,6 +3739,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", @@ -3495,6 +3794,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3516,6 +3828,12 @@ "node": ">=0.10.0" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3755,6 +4073,16 @@ "node": ">=8.0" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -3773,6 +4101,13 @@ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", "dev": true }, + "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==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4103,6 +4438,16 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4121,6 +4466,80 @@ "node": ">= 14" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4162,4 +4581,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 1b61c3f..c8ebacb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,11 @@ "electron": "electron electron/main.js", "electron:dev": "NODE_ENV=development electron electron/main.js", "dist": "electron-builder", - "update-version": "node scripts/update-version.cjs" + "update-version": "node scripts/update-version.cjs", + "dev:server": "cd server && npm run dev", + "dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"", + "build:server": "cd server && npm run build", + "build:all": "npm run build && npm run build:server" }, "dependencies": { "date-fns": "^3.3.1", @@ -37,6 +41,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", "vite": "^5.4.2", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "concurrently": "^8.2.0" } } diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..abd944c --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +data/*.db +data/.encryption-key diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..07a5241 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,16 @@ +FROM node:18-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:18-alpine +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/package.json ./ +RUN mkdir -p data +VOLUME /app/data +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/server/data/.gitkeep b/server/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..32a2bd5 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,4146 @@ +{ + "name": "github-stars-manager-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "github-stars-manager-server", + "version": "0.1.0", + "dependencies": { + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "express": "^4.21.0", + "helmet": "^7.1.0", + "morgan": "^1.10.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.9", + "@types/supertest": "^6.0.2", + "supertest": "^6.3.4", + "tsx": "^4.7.0", + "typescript": "^5.5.3", + "vitest": "^1.6.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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, + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.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==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/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" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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, + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=4.0.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "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.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "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/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.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==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "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, + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "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==", + "license": "MIT", + "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==", + "license": "MIT" + }, + "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" + } + ], + "license": "MIT", + "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==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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" + } + ], + "license": "MIT", + "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.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/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, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "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" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/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, + "license": "BSD-3-Clause", + "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, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.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==", + "license": "MIT", + "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==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "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==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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, + "license": "MIT", + "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==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "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": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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, + "license": "MIT", + "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": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "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": "1.6.1", + "@vitest/ui": "1.6.1", + "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, + "license": "MIT", + "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, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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, + "license": "MIT", + "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==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..eb19c2e --- /dev/null +++ b/server/package.json @@ -0,0 +1,31 @@ +{ + "name": "github-stars-manager-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "express": "^4.21.0", + "better-sqlite3": "^11.0.0", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "morgan": "^1.10.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/better-sqlite3": "^7.6.8", + "@types/cors": "^2.8.17", + "@types/morgan": "^1.9.9", + "tsx": "^4.7.0", + "typescript": "^5.5.3", + "vitest": "^1.6.0", + "supertest": "^6.3.4", + "@types/supertest": "^6.0.2" + } +} diff --git a/server/postcss.config.js b/server/postcss.config.js new file mode 100644 index 0000000..e74673a --- /dev/null +++ b/server/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: [] +}; diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..de13a10 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,50 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +interface Config { + port: number; + apiSecret: string | null; + encryptionKey: string; + dbPath: string; + nodeEnv: string; +} + +function resolveDataDir(): string { + const dataDir = path.resolve(process.cwd(), 'data'); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + return dataDir; +} + +function resolveEncryptionKey(dataDir: string): string { + const envKey = process.env.ENCRYPTION_KEY; + if (envKey) { + return envKey; + } + + const keyFilePath = path.join(dataDir, '.encryption-key'); + if (fs.existsSync(keyFilePath)) { + return fs.readFileSync(keyFilePath, 'utf-8').trim(); + } + + const newKey = crypto.randomBytes(32).toString('hex'); + fs.writeFileSync(keyFilePath, newKey, { mode: 0o600 }); + console.log('Generated new encryption key and saved to data/.encryption-key'); + return newKey; +} + +function loadConfig(): Config { + const dataDir = resolveDataDir(); + + return { + port: parseInt(process.env.PORT || '3000', 10), + apiSecret: process.env.API_SECRET || null, + encryptionKey: resolveEncryptionKey(dataDir), + dbPath: process.env.DB_PATH || path.join(dataDir, 'data.db'), + nodeEnv: process.env.NODE_ENV || 'development', + }; +} + +export const config = loadConfig(); diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts new file mode 100644 index 0000000..5ea74bd --- /dev/null +++ b/server/src/db/connection.ts @@ -0,0 +1,29 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { config } from '../config.js'; + +let db: Database.Database | null = null; + +export function getDb(): Database.Database { + if (db) return db; + + // Ensure directory exists + const dir = path.dirname(config.dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + db = new Database(config.dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + return db; +} + +export function closeDb(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts new file mode 100644 index 0000000..5d65e69 --- /dev/null +++ b/server/src/db/migrations.ts @@ -0,0 +1,43 @@ +import type Database from 'better-sqlite3'; +import { initializeSchema } from './schema.js'; + +const migrations: Record void> = { + 1: (db) => { + initializeSchema(db); + }, +}; + +export function runMigrations(db: Database.Database): void { + // Ensure schema_version table exists first + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `); + + const currentVersionRow = db + .prepare('SELECT MAX(version) as version FROM schema_version') + .get() as { version: number | null } | undefined; + + const currentVersion = currentVersionRow?.version ?? 0; + const targetVersion = Math.max(...Object.keys(migrations).map(Number)); + + if (currentVersion >= targetVersion) { + return; + } + + const applyMigration = db.transaction(() => { + for (let v = currentVersion + 1; v <= targetVersion; v++) { + const migration = migrations[v]; + if (migration) { + console.log(`Applying migration v${v}...`); + migration(db); + db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(v); + console.log(`Migration v${v} applied.`); + } + } + }); + + applyMigration(); +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..ef5e2e7 --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,93 @@ +import type Database from 'better-sqlite3'; + +export function initializeSchema(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS repositories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + full_name TEXT NOT NULL UNIQUE, + description TEXT, + html_url TEXT NOT NULL, + stargazers_count INTEGER DEFAULT 0, + language TEXT, + created_at TEXT, + updated_at TEXT, + pushed_at TEXT, + starred_at TEXT, + owner_login TEXT NOT NULL, + owner_avatar_url TEXT, + topics TEXT, + ai_summary TEXT, + ai_tags TEXT, + ai_platforms TEXT, + analyzed_at TEXT, + analysis_failed INTEGER DEFAULT 0, + custom_description TEXT, + custom_tags TEXT, + custom_category TEXT, + last_edited TEXT, + subscribed_to_releases INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS releases ( + id INTEGER PRIMARY KEY, + tag_name TEXT NOT NULL, + name TEXT, + body TEXT, + published_at TEXT, + html_url TEXT, + assets TEXT, + repo_id INTEGER NOT NULL, + repo_full_name TEXT NOT NULL, + repo_name TEXT NOT NULL, + is_read INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📁', + keywords TEXT, + is_custom INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS ai_configs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_type TEXT DEFAULT 'openai', + base_url TEXT NOT NULL, + api_key_encrypted TEXT NOT NULL, + model TEXT NOT NULL, + is_active INTEGER DEFAULT 0, + custom_prompt TEXT, + use_custom_prompt INTEGER DEFAULT 0, + concurrency INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS webdav_configs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '/', + is_active INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS asset_filters ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + keywords TEXT + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..356cb6d --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,82 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import { config } from './config.js'; +import { authMiddleware } from './middleware/auth.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { getDb, closeDb } from './db/connection.js'; +import { runMigrations } from './db/migrations.js'; +import healthRouter from './routes/health.js'; +import repositoriesRouter from './routes/repositories.js'; +import releasesRouter from './routes/releases.js'; +import categoriesRouter from './routes/categories.js'; +import configsRouter from './routes/configs.js'; +import syncRouter from './routes/sync.js'; +import proxyRouter from './routes/proxy.js'; + +export function createApp(): express.Express { + const app = express(); + + // Middleware + app.use(helmet()); + app.use(cors()); + app.use(morgan('combined')); + app.use(express.json({ limit: '50mb' })); + + // Auth middleware for all /api/* except /api/health + app.use('/api', authMiddleware); + + // Routes + app.use(healthRouter); + + // Wave 2: Data CRUD routes + app.use(repositoriesRouter); + app.use(releasesRouter); + app.use(categoriesRouter); + app.use(configsRouter); + app.use(syncRouter); + + // Wave 3: Proxy routes + app.use(proxyRouter); + + // Global error handler + app.use(errorHandler); + + return app; +} + +function startServer(): void { + // Initialize database + const db = getDb(); + runMigrations(db); + console.log('✅ Database initialized'); + + const app = createApp(); + + const server = app.listen(config.port, () => { + console.log(`🚀 Server running on port ${config.port}`); + if (!config.apiSecret) { + console.warn('⚠️ Running without API_SECRET — auth is disabled'); + } + }); + + // Graceful shutdown + const shutdown = () => { + console.log('\n🛑 Shutting down...'); + server.close(() => { + closeDb(); + console.log('👋 Server stopped'); + process.exit(0); + }); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +// Only start server when run directly (not imported for tests) +const isMainModule = process.argv[1] && new URL(import.meta.url).pathname === (process.platform === 'win32' ? process.argv[1].replace(/\\/g, '/') : process.argv[1]); +if (isMainModule) { + startServer(); +} diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..232b47d --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,42 @@ +import crypto from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { config } from '../config.js'; + +let warnedOnce = false; + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + // Skip auth for health check + if (req.method === 'GET' && req.path === '/api/health') { + next(); + return; + } + + // Dev mode: no API_SECRET set + if (!config.apiSecret) { + if (!warnedOnce) { + console.warn('⚠️ API_SECRET not set — auth disabled (dev mode)'); + warnedOnce = true; + } + next(); + return; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }); + return; + } + + const token = authHeader.slice(7); + + // Constant-time comparison + const tokenBuf = Buffer.from(token); + const secretBuf = Buffer.from(config.apiSecret); + + if (tokenBuf.length !== secretBuf.length || !crypto.timingSafeEqual(tokenBuf, secretBuf)) { + res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }); + return; + } + + next(); +} diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts new file mode 100644 index 0000000..749827c --- /dev/null +++ b/server/src/middleware/errorHandler.ts @@ -0,0 +1,18 @@ +import type { Request, Response, NextFunction } from 'express'; + +export function errorHandler( + err: Error, + _req: Request, + res: Response, + _next: NextFunction +): void { + console.error('Unhandled error:', err.stack || err.message); + + const statusCode = (err as Error & { statusCode?: number }).statusCode || 500; + const message = + process.env.NODE_ENV === 'production' + ? 'Internal Server Error' + : err.message || 'Internal Server Error'; + + res.status(statusCode).json({ error: message, code: 'INTERNAL_SERVER_ERROR' }); +} diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts new file mode 100644 index 0000000..2943499 --- /dev/null +++ b/server/src/routes/categories.ts @@ -0,0 +1,194 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; + +const router = Router(); + +function parseJsonColumn(value: unknown): unknown[] { + if (typeof value !== 'string' || !value) return []; + try { return JSON.parse(value); } catch { return []; } +} + +// ── Categories ── + +function transformCategory(row: Record) { + return { + id: row.id, + name: row.name, + description: row.description, + keywords: parseJsonColumn(row.keywords), + color: row.color, + icon: row.icon, + sort_order: row.sort_order, + }; +} + +// GET /api/categories +router.get('/api/categories', (_req, res) => { + try { + const db = getDb(); + const rows = db.prepare('SELECT * FROM categories ORDER BY sort_order ASC, name ASC').all() as Record[]; + res.json(rows.map(transformCategory)); + } catch (err) { + console.error('GET /api/categories error:', err); + res.status(500).json({ error: 'Failed to fetch categories', code: 'FETCH_CATEGORIES_FAILED' }); + } +}); + +// POST /api/categories +router.post('/api/categories', (req, res) => { + try { + const db = getDb(); + const { name, description, keywords, color, icon, sort_order } = req.body as Record; + + const result = db.prepare( + 'INSERT INTO categories (name, description, keywords, color, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + name ?? '', description ?? null, + JSON.stringify(keywords ?? []), + color ?? null, icon ?? null, sort_order ?? 0 + ); + + const row = db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid) as Record; + res.status(201).json(transformCategory(row)); + } catch (err) { + console.error('POST /api/categories error:', err); + res.status(500).json({ error: 'Failed to create category', code: 'CREATE_CATEGORY_FAILED' }); + } +}); + +// PUT /api/categories/:id +router.put('/api/categories/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const { name, description, keywords, color, icon, sort_order } = req.body as Record; + + db.prepare( + 'UPDATE categories SET name = ?, description = ?, keywords = ?, color = ?, icon = ?, sort_order = ? WHERE id = ?' + ).run( + name ?? '', description ?? null, + JSON.stringify(keywords ?? []), + color ?? null, icon ?? null, sort_order ?? 0, id + ); + + const row = db.prepare('SELECT * FROM categories WHERE id = ?').get(id) as Record | undefined; + if (!row) { + res.status(404).json({ error: 'Category not found', code: 'CATEGORY_NOT_FOUND' }); + return; + } + res.json(transformCategory(row)); + } catch (err) { + console.error('PUT /api/categories error:', err); + res.status(500).json({ error: 'Failed to update category', code: 'UPDATE_CATEGORY_FAILED' }); + } +}); + +// DELETE /api/categories/:id +router.delete('/api/categories/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const result = db.prepare('DELETE FROM categories WHERE id = ?').run(id); + if (result.changes === 0) { + res.status(404).json({ error: 'Category not found', code: 'CATEGORY_NOT_FOUND' }); + return; + } + res.json({ deleted: true }); + } catch (err) { + console.error('DELETE /api/categories error:', err); + res.status(500).json({ error: 'Failed to delete category', code: 'DELETE_CATEGORY_FAILED' }); + } +}); + +// ── Asset Filters ── + +function transformAssetFilter(row: Record) { + return { + id: row.id, + name: row.name, + description: row.description, + keywords: parseJsonColumn(row.keywords), + platform: row.platform, + sort_order: row.sort_order, + }; +} + +// GET /api/asset-filters +router.get('/api/asset-filters', (_req, res) => { + try { + const db = getDb(); + const rows = db.prepare('SELECT * FROM asset_filters ORDER BY sort_order ASC, name ASC').all() as Record[]; + res.json(rows.map(transformAssetFilter)); + } catch (err) { + console.error('GET /api/asset-filters error:', err); + res.status(500).json({ error: 'Failed to fetch asset filters', code: 'FETCH_ASSET_FILTERS_FAILED' }); + } +}); + +// POST /api/asset-filters +router.post('/api/asset-filters', (req, res) => { + try { + const db = getDb(); + const { name, description, keywords, platform, sort_order } = req.body as Record; + + const result = db.prepare( + 'INSERT INTO asset_filters (name, description, keywords, platform, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run( + name ?? '', description ?? null, + JSON.stringify(keywords ?? []), + platform ?? null, sort_order ?? 0 + ); + + const row = db.prepare('SELECT * FROM asset_filters WHERE id = ?').get(result.lastInsertRowid) as Record; + res.status(201).json(transformAssetFilter(row)); + } catch (err) { + console.error('POST /api/asset-filters error:', err); + res.status(500).json({ error: 'Failed to create asset filter', code: 'CREATE_ASSET_FILTER_FAILED' }); + } +}); + +// PUT /api/asset-filters/:id +router.put('/api/asset-filters/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const { name, description, keywords, platform, sort_order } = req.body as Record; + + db.prepare( + 'UPDATE asset_filters SET name = ?, description = ?, keywords = ?, platform = ?, sort_order = ? WHERE id = ?' + ).run( + name ?? '', description ?? null, + JSON.stringify(keywords ?? []), + platform ?? null, sort_order ?? 0, id + ); + + const row = db.prepare('SELECT * FROM asset_filters WHERE id = ?').get(id) as Record | undefined; + if (!row) { + res.status(404).json({ error: 'Asset filter not found', code: 'ASSET_FILTER_NOT_FOUND' }); + return; + } + res.json(transformAssetFilter(row)); + } catch (err) { + console.error('PUT /api/asset-filters error:', err); + res.status(500).json({ error: 'Failed to update asset filter', code: 'UPDATE_ASSET_FILTER_FAILED' }); + } +}); + +// DELETE /api/asset-filters/:id +router.delete('/api/asset-filters/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const result = db.prepare('DELETE FROM asset_filters WHERE id = ?').run(id); + if (result.changes === 0) { + res.status(404).json({ error: 'Asset filter not found', code: 'ASSET_FILTER_NOT_FOUND' }); + return; + } + res.json({ deleted: true }); + } catch (err) { + console.error('DELETE /api/asset-filters error:', err); + res.status(500).json({ error: 'Failed to delete asset filter', code: 'DELETE_ASSET_FILTER_FAILED' }); + } +}); + +export default router; diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts new file mode 100644 index 0000000..bab746f --- /dev/null +++ b/server/src/routes/configs.ts @@ -0,0 +1,295 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; +import { encrypt, decrypt } from '../services/crypto.js'; +import { config } from '../config.js'; + +const router = Router(); + +// ── AI Configs ── + +function maskApiKey(key: string | null | undefined): string { + if (!key || typeof key !== 'string') return ''; + if (key.length <= 4) return '****'; + return '***' + key.slice(-4); +} + +// GET /api/configs/ai +router.get('/api/configs/ai', (_req, res) => { + try { + const db = getDb(); + const rows = db.prepare('SELECT * FROM ai_configs ORDER BY id ASC').all() as Record[]; + const configs = rows.map((row) => { + let decryptedKey = ''; + try { + if (row.api_key_encrypted && typeof row.api_key_encrypted === 'string') { + decryptedKey = decrypt(row.api_key_encrypted, config.encryptionKey); + } + } catch { /* leave empty */ } + return { + id: row.id, + name: row.name, + provider: row.provider, + model: row.model, + base_url: row.base_url, + api_key: maskApiKey(decryptedKey), + is_default: !!row.is_default, + created_at: row.created_at, + updated_at: row.updated_at, + }; + }); + res.json(configs); + } catch (err) { + console.error('GET /api/configs/ai error:', err); + res.status(500).json({ error: 'Failed to fetch AI configs', code: 'FETCH_AI_CONFIGS_FAILED' }); + } +}); + +// POST /api/configs/ai +router.post('/api/configs/ai', (req, res) => { + try { + const db = getDb(); + const { name, provider, model, base_url, apiKey, is_default } = req.body as Record; + + const encryptedKey = apiKey && typeof apiKey === 'string' ? encrypt(apiKey, config.encryptionKey) : null; + const now = new Date().toISOString(); + + const result = db.prepare( + 'INSERT INTO ai_configs (name, provider, model, base_url, api_key_encrypted, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run( + name ?? '', provider ?? '', model ?? '', base_url ?? null, + encryptedKey, is_default ? 1 : 0, now, now + ); + + res.status(201).json({ id: result.lastInsertRowid, name, provider, model, base_url, api_key: maskApiKey(apiKey as string), is_default: !!is_default }); + } catch (err) { + console.error('POST /api/configs/ai error:', err); + res.status(500).json({ error: 'Failed to create AI config', code: 'CREATE_AI_CONFIG_FAILED' }); + } +}); + +// PUT /api/configs/ai/:id +router.put('/api/configs/ai/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const { name, provider, model, base_url, apiKey, is_default } = req.body as Record; + + let encryptedKey: string | null = null; + if (apiKey && typeof apiKey === 'string' && !apiKey.startsWith('***')) { + encryptedKey = encrypt(apiKey, config.encryptionKey); + } else { + // Keep existing encrypted key + const existing = db.prepare('SELECT api_key_encrypted FROM ai_configs WHERE id = ?').get(id) as Record | undefined; + encryptedKey = (existing?.api_key_encrypted as string) ?? null; + } + + const now = new Date().toISOString(); + db.prepare( + 'UPDATE ai_configs SET name = ?, provider = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_default = ?, updated_at = ? WHERE id = ?' + ).run(name ?? '', provider ?? '', model ?? '', base_url ?? null, encryptedKey, is_default ? 1 : 0, now, id); + + let maskedKey = ''; + if (encryptedKey) { + try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } + } + + res.json({ id, name, provider, model, base_url, api_key: maskedKey, is_default: !!is_default }); + } catch (err) { + console.error('PUT /api/configs/ai error:', err); + res.status(500).json({ error: 'Failed to update AI config', code: 'UPDATE_AI_CONFIG_FAILED' }); + } +}); + +// DELETE /api/configs/ai/:id +router.delete('/api/configs/ai/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const result = db.prepare('DELETE FROM ai_configs WHERE id = ?').run(id); + if (result.changes === 0) { + res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + return; + } + res.json({ deleted: true }); + } catch (err) { + console.error('DELETE /api/configs/ai error:', err); + res.status(500).json({ error: 'Failed to delete AI config', code: 'DELETE_AI_CONFIG_FAILED' }); + } +}); + +// ── WebDAV Configs ── + +function maskPassword(pwd: string | null | undefined): string { + if (!pwd || typeof pwd !== 'string') return ''; + if (pwd.length <= 4) return '****'; + return '***' + pwd.slice(-4); +} + +// GET /api/configs/webdav +router.get('/api/configs/webdav', (_req, res) => { + try { + const db = getDb(); + const rows = db.prepare('SELECT * FROM webdav_configs ORDER BY id ASC').all() as Record[]; + const configs = rows.map((row) => { + let decryptedPwd = ''; + try { + if (row.password_encrypted && typeof row.password_encrypted === 'string') { + decryptedPwd = decrypt(row.password_encrypted, config.encryptionKey); + } + } catch { /* leave empty */ } + return { + id: row.id, + name: row.name, + url: row.url, + username: row.username, + password: maskPassword(decryptedPwd), + path: row.path, + is_default: !!row.is_default, + created_at: row.created_at, + updated_at: row.updated_at, + }; + }); + res.json(configs); + } catch (err) { + console.error('GET /api/configs/webdav error:', err); + res.status(500).json({ error: 'Failed to fetch WebDAV configs', code: 'FETCH_WEBDAV_CONFIGS_FAILED' }); + } +}); + +// POST /api/configs/webdav +router.post('/api/configs/webdav', (req, res) => { + try { + const db = getDb(); + const { name, url, username, password, path, is_default } = req.body as Record; + + const encryptedPwd = password && typeof password === 'string' ? encrypt(password, config.encryptionKey) : null; + const now = new Date().toISOString(); + + const result = db.prepare( + 'INSERT INTO webdav_configs (name, url, username, password_encrypted, path, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).run( + name ?? '', url ?? '', username ?? '', encryptedPwd, + path ?? '/', is_default ? 1 : 0, now, now + ); + + res.status(201).json({ id: result.lastInsertRowid, name, url, username, password: maskPassword(password as string), path, is_default: !!is_default }); + } catch (err) { + console.error('POST /api/configs/webdav error:', err); + res.status(500).json({ error: 'Failed to create WebDAV config', code: 'CREATE_WEBDAV_CONFIG_FAILED' }); + } +}); + +// PUT /api/configs/webdav/:id +router.put('/api/configs/webdav/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const { name, url, username, password, path, is_default } = req.body as Record; + + let encryptedPwd: string | null = null; + if (password && typeof password === 'string' && !password.startsWith('***')) { + encryptedPwd = encrypt(password, config.encryptionKey); + } else { + const existing = db.prepare('SELECT password_encrypted FROM webdav_configs WHERE id = ?').get(id) as Record | undefined; + encryptedPwd = (existing?.password_encrypted as string) ?? null; + } + + const now = new Date().toISOString(); + db.prepare( + 'UPDATE webdav_configs SET name = ?, url = ?, username = ?, password_encrypted = ?, path = ?, is_default = ?, updated_at = ? WHERE id = ?' + ).run(name ?? '', url ?? '', username ?? '', encryptedPwd, path ?? '/', is_default ? 1 : 0, now, id); + + let maskedPwd = ''; + if (encryptedPwd) { + try { maskedPwd = maskPassword(decrypt(encryptedPwd, config.encryptionKey)); } catch { maskedPwd = '****'; } + } + + res.json({ id, name, url, username, password: maskedPwd, path, is_default: !!is_default }); + } catch (err) { + console.error('PUT /api/configs/webdav error:', err); + res.status(500).json({ error: 'Failed to update WebDAV config', code: 'UPDATE_WEBDAV_CONFIG_FAILED' }); + } +}); + +// DELETE /api/configs/webdav/:id +router.delete('/api/configs/webdav/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const result = db.prepare('DELETE FROM webdav_configs WHERE id = ?').run(id); + if (result.changes === 0) { + res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); + return; + } + res.json({ deleted: true }); + } catch (err) { + console.error('DELETE /api/configs/webdav error:', err); + res.status(500).json({ error: 'Failed to delete WebDAV config', code: 'DELETE_WEBDAV_CONFIG_FAILED' }); + } +}); + +// ── Settings ── + +// GET /api/settings +router.get('/api/settings', (_req, res) => { + try { + const db = getDb(); + const rows = db.prepare('SELECT * FROM settings').all() as Record[]; + const settings: Record = {}; + + for (const row of rows) { + const key = row.key as string; + let value = row.value as string | null; + + if (key === 'github_token' && value) { + try { + const decrypted = decrypt(value, config.encryptionKey); + value = maskApiKey(decrypted); + } catch { + value = '****'; + } + } + + settings[key] = value; + } + + res.json(settings); + } catch (err) { + console.error('GET /api/settings error:', err); + res.status(500).json({ error: 'Failed to fetch settings', code: 'FETCH_SETTINGS_FAILED' }); + } +}); + +// PUT /api/settings +router.put('/api/settings', (req, res) => { + try { + const db = getDb(); + const updates = req.body as Record; + + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + + const upsert = db.transaction(() => { + for (const [key, rawValue] of Object.entries(updates)) { + let value = rawValue as string | null; + + if (key === 'github_token' && value && typeof value === 'string') { + if (value.startsWith('***')) { + // Skip masked values — keep existing + continue; + } + value = encrypt(value, config.encryptionKey); + } + + stmt.run(key, value ?? null); + } + }); + + upsert(); + res.json({ updated: true }); + } catch (err) { + console.error('PUT /api/settings error:', err); + res.status(500).json({ error: 'Failed to update settings', code: 'UPDATE_SETTINGS_FAILED' }); + } +}); + +export default router; diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts new file mode 100644 index 0000000..8f5f8d5 --- /dev/null +++ b/server/src/routes/health.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; + +const router = Router(); + +router.get('/api/health', (_req, res) => { + res.json({ + status: 'ok', + version: '0.1.0', + timestamp: new Date().toISOString(), + }); +}); + +export default router; diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts new file mode 100644 index 0000000..4e91fb3 --- /dev/null +++ b/server/src/routes/proxy.ts @@ -0,0 +1,190 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; +import { decrypt } from '../services/crypto.js'; +import { config } from '../config.js'; +import { proxyRequest } from '../services/proxyService.js'; + +const router = Router(); + +// Helper: build API URL handling baseUrl already ending in version prefix +function buildApiUrl(baseUrl: string, pathWithVersion: string): string { + const baseUrlWithSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const versionPrefix = pathWithVersion.split('/')[0] || ''; + + try { + const base = new URL(baseUrlWithSlash); + const basePath = base.pathname.replace(/\/$/, ''); + + if (versionPrefix) { + const versionRe = new RegExp(`/${versionPrefix}$`); + if (versionRe.test(basePath) && pathWithVersion.startsWith(`${versionPrefix}/`)) { + const rest = pathWithVersion.slice(versionPrefix.length + 1); + return new URL(rest, baseUrlWithSlash).toString(); + } + } + + return new URL(pathWithVersion, baseUrlWithSlash).toString(); + } catch { + return `${baseUrlWithSlash}${pathWithVersion}`; + } +} + +// POST /api/proxy/github/* +router.post('/api/proxy/github/*', async (req, res) => { + try { + const db = getDb(); + const githubPath = (req.params as Record)[0]; // wildcard capture + + // Read and decrypt GitHub token from settings + const tokenRow = db.prepare('SELECT value FROM settings WHERE key = ?').get('github_token') as { value: string } | undefined; + if (!tokenRow?.value) { + res.status(400).json({ error: 'GitHub token not configured', code: 'GITHUB_TOKEN_NOT_CONFIGURED' }); + return; + } + + let token: string; + try { + token = decrypt(tokenRow.value, config.encryptionKey); + } catch { + res.status(500).json({ error: 'Failed to decrypt GitHub token', code: 'GITHUB_TOKEN_DECRYPT_FAILED' }); + return; + } + + // Build target URL with query params + const queryString = new URL(req.url, 'http://localhost').search; + const targetUrl = `https://api.github.com/${githubPath}${queryString}`; + + const body = req.body as { method?: string; headers?: Record }; + const method = body.method || 'GET'; + + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Accept': body.headers?.Accept || 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'GithubStarsManager-Backend', + }; + + const result = await proxyRequest({ url: targetUrl, method, headers }); + res.status(result.status).json(result.data); + } catch (err) { + console.error('GitHub proxy error:', err); + res.status(500).json({ error: 'GitHub proxy failed', code: 'GITHUB_PROXY_FAILED' }); + } +}); + +// POST /api/proxy/ai +router.post('/api/proxy/ai', async (req, res) => { + try { + const db = getDb(); + const { configId, body: requestBody } = req.body as { configId: string; body: Record }; + + if (!configId) { + res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' }); + return; + } + + const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(configId) as Record | undefined; + if (!aiConfig) { + res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + return; + } + + const apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); + const apiType = (aiConfig.api_type as string) || 'openai'; + const baseUrl = aiConfig.base_url as string; + const model = aiConfig.model as string; + + let targetUrl: string; + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + if (apiType === 'openai') { + targetUrl = buildApiUrl(baseUrl, 'v1/chat/completions'); + headers['Authorization'] = `Bearer ${apiKey}`; + } else if (apiType === 'claude') { + targetUrl = buildApiUrl(baseUrl, 'v1/messages'); + headers['x-api-key'] = apiKey; + headers['anthropic-version'] = '2023-06-01'; + } else { + // gemini + const rawModel = model.trim(); + const modelName = rawModel.startsWith('models/') ? rawModel.slice('models/'.length) : rawModel; + const path = `v1beta/models/${encodeURIComponent(modelName)}:generateContent`; + targetUrl = buildApiUrl(baseUrl, path); + const urlObj = new URL(targetUrl); + urlObj.searchParams.set('key', apiKey); + targetUrl = urlObj.toString(); + } + + const result = await proxyRequest({ + url: targetUrl, + method: 'POST', + headers, + body: requestBody, + timeout: 60000, + }); + + res.status(result.status).json(result.data); + } catch (err) { + console.error('AI proxy error:', err); + res.status(500).json({ error: 'AI proxy failed', code: 'AI_PROXY_FAILED' }); + } +}); + +// POST /api/proxy/webdav +router.post('/api/proxy/webdav', async (req, res) => { + try { + const db = getDb(); + const { configId, method, path, body: requestBody, headers: extraHeaders } = req.body as { + configId: string; + method: string; + path: string; + body?: string; + headers?: Record; + }; + + if (!configId) { + res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' }); + return; + } + + const webdavConfig = db.prepare('SELECT * FROM webdav_configs WHERE id = ?').get(configId) as Record | undefined; + if (!webdavConfig) { + res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); + return; + } + + const password = decrypt(webdavConfig.password_encrypted as string, config.encryptionKey); + const username = webdavConfig.username as string; + const baseUrl = webdavConfig.url as string; + + const targetUrl = `${baseUrl}${path}`; + const credentials = Buffer.from(`${username}:${password}`).toString('base64'); + + const headers: Record = { + 'Authorization': `Basic ${credentials}`, + ...(extraHeaders || {}), + }; + + if (method === 'PROPFIND') { + headers['Content-Type'] = headers['Content-Type'] || 'application/xml'; + } + + const result = await proxyRequest({ + url: targetUrl, + method, + headers, + body: requestBody, + timeout: 60000, + }); + + res.status(result.status).json(result.data); + } catch (err) { + console.error('WebDAV proxy error:', err); + res.status(500).json({ error: 'WebDAV proxy failed', code: 'WEBDAV_PROXY_FAILED' }); + } +}); + +export default router; diff --git a/server/src/routes/releases.ts b/server/src/routes/releases.ts new file mode 100644 index 0000000..6bf3495 --- /dev/null +++ b/server/src/routes/releases.ts @@ -0,0 +1,166 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; + +const router = Router(); + +function parseJsonColumn(value: unknown): unknown[] { + if (typeof value !== 'string' || !value) return []; + try { return JSON.parse(value); } catch { return []; } +} + +function transformRelease(row: Record) { + return { + id: row.id, + tag_name: row.tag_name, + name: row.name, + body: row.body, + html_url: row.html_url, + published_at: row.published_at, + prerelease: !!row.prerelease, + draft: !!row.draft, + is_read: !!row.is_read, + assets: parseJsonColumn(row.assets), + repository: { + id: row.repo_id, + full_name: row.repo_full_name, + name: row.repo_name, + }, + }; +} + +// GET /api/releases +router.get('/api/releases', (req, res) => { + try { + const db = getDb(); + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 50; + const repoId = req.query.repo_id as string | undefined; + const unread = req.query.unread as string | undefined; + const offset = (page - 1) * limit; + + let sql = 'SELECT * FROM releases'; + const conditions: string[] = []; + const params: unknown[] = []; + + if (repoId) { + conditions.push('repo_id = ?'); + params.push(parseInt(repoId)); + } + if (unread === 'true') { + conditions.push('is_read = 0'); + } + + if (conditions.length > 0) { + sql += ' WHERE ' + conditions.join(' AND '); + } + + sql += ' ORDER BY published_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const rows = db.prepare(sql).all(...params) as Record[]; + const releases = rows.map(transformRelease); + + let countSql = 'SELECT COUNT(*) as total FROM releases'; + const countParams: unknown[] = []; + if (conditions.length > 0) { + countSql += ' WHERE ' + conditions.join(' AND '); + if (repoId) countParams.push(parseInt(repoId)); + } + const countRow = db.prepare(countSql).get(...countParams) as { total: number }; + + res.json({ releases, total: countRow.total, page, limit }); + } catch (err) { + console.error('GET /api/releases error:', err); + res.status(500).json({ error: 'Failed to fetch releases', code: 'FETCH_RELEASES_FAILED' }); + } +}); + +// PUT /api/releases (bulk upsert) +router.put('/api/releases', (req, res) => { + try { + const db = getDb(); + const { releases } = req.body as { releases: Record[] }; + if (!Array.isArray(releases)) { + res.status(400).json({ error: 'releases array required', code: 'RELEASES_ARRAY_REQUIRED' }); + return; + } + + const stmt = db.prepare(` + INSERT OR REPLACE INTO releases ( + id, tag_name, name, body, html_url, published_at, + prerelease, draft, is_read, assets, + repo_id, repo_full_name, repo_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const upsert = db.transaction(() => { + let count = 0; + for (const release of releases) { + const repository = release.repository as { id?: number; full_name?: string; name?: string } | undefined; + stmt.run( + release.id, + release.tag_name ?? null, + release.name ?? null, + release.body ?? null, + release.html_url ?? null, + release.published_at ?? null, + release.prerelease ? 1 : 0, + release.draft ? 1 : 0, + release.is_read ? 1 : 0, + JSON.stringify(release.assets ?? []), + repository?.id ?? release.repo_id ?? null, + repository?.full_name ?? release.repo_full_name ?? null, + repository?.name ?? release.repo_name ?? null + ); + count++; + } + return count; + }); + + const count = upsert(); + res.json({ upserted: count }); + } catch (err) { + console.error('PUT /api/releases error:', err); + res.status(500).json({ error: 'Failed to upsert releases', code: 'UPSERT_RELEASES_FAILED' }); + } +}); + +// PATCH /api/releases/:id +router.patch('/api/releases/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const { is_read } = req.body as { is_read?: boolean }; + + if (is_read === undefined) { + res.status(400).json({ error: 'is_read field required', code: 'IS_READ_REQUIRED' }); + return; + } + + db.prepare('UPDATE releases SET is_read = ? WHERE id = ?').run(is_read ? 1 : 0, id); + + const row = db.prepare('SELECT * FROM releases WHERE id = ?').get(id) as Record | undefined; + if (!row) { + res.status(404).json({ error: 'Release not found', code: 'RELEASE_NOT_FOUND' }); + return; + } + res.json(transformRelease(row)); + } catch (err) { + console.error('PATCH /api/releases error:', err); + res.status(500).json({ error: 'Failed to update release', code: 'UPDATE_RELEASE_FAILED' }); + } +}); + +// POST /api/releases/mark-all-read +router.post('/api/releases/mark-all-read', (_req, res) => { + try { + const db = getDb(); + const result = db.prepare('UPDATE releases SET is_read = 1').run(); + res.json({ updated: result.changes }); + } catch (err) { + console.error('POST /api/releases/mark-all-read error:', err); + res.status(500).json({ error: 'Failed to mark all as read', code: 'MARK_ALL_READ_FAILED' }); + } +}); + +export default router; diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts new file mode 100644 index 0000000..ece1d95 --- /dev/null +++ b/server/src/routes/repositories.ts @@ -0,0 +1,184 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; + +const router = Router(); + +// Helper to parse JSON columns safely +function parseJsonColumn(value: unknown): unknown[] { + if (typeof value !== 'string' || !value) return []; + try { return JSON.parse(value); } catch { return []; } +} + +// Helper to transform DB row to API response +function transformRepo(row: Record) { + return { + id: row.id, + name: row.name, + full_name: row.full_name, + description: row.description, + html_url: row.html_url, + stargazers_count: row.stargazers_count, + language: row.language, + created_at: row.created_at, + updated_at: row.updated_at, + pushed_at: row.pushed_at, + starred_at: row.starred_at, + owner: { login: row.owner_login, avatar_url: row.owner_avatar_url }, + topics: parseJsonColumn(row.topics), + ai_summary: row.ai_summary, + ai_tags: parseJsonColumn(row.ai_tags), + ai_platforms: parseJsonColumn(row.ai_platforms), + analyzed_at: row.analyzed_at, + analysis_failed: !!row.analysis_failed, + custom_description: row.custom_description, + custom_tags: parseJsonColumn(row.custom_tags), + custom_category: row.custom_category, + last_edited: row.last_edited, + subscribed_to_releases: !!row.subscribed_to_releases, + }; +} + +// GET /api/repositories +router.get('/api/repositories', (req, res) => { + try { + const db = getDb(); + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 100; + const search = req.query.search as string | undefined; + const offset = (page - 1) * limit; + + let sql = 'SELECT * FROM repositories'; + const params: unknown[] = []; + + if (search) { + sql += ' WHERE name LIKE ? OR full_name LIKE ? OR description LIKE ? OR ai_summary LIKE ? OR ai_tags LIKE ?'; + const searchPattern = `%${search}%`; + params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern); + } + + sql += ' ORDER BY stargazers_count DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + const rows = db.prepare(sql).all(...params) as Record[]; + const repositories = rows.map(transformRepo); + + const countSql = search + ? 'SELECT COUNT(*) as total FROM repositories WHERE name LIKE ? OR full_name LIKE ? OR description LIKE ? OR ai_summary LIKE ? OR ai_tags LIKE ?' + : 'SELECT COUNT(*) as total FROM repositories'; + const countParams = search ? Array(5).fill(`%${search}%`) : []; + const countRow = db.prepare(countSql).get(...countParams) as { total: number }; + + res.json({ repositories, total: countRow.total, page, limit }); + } catch (err) { + console.error('GET /api/repositories error:', err); + res.status(500).json({ error: 'Failed to fetch repositories', code: 'FETCH_REPOSITORIES_FAILED' }); + } +}); + +// PUT /api/repositories (bulk upsert) +router.put('/api/repositories', (req, res) => { + try { + const db = getDb(); + const { repositories } = req.body as { repositories: Record[] }; + if (!Array.isArray(repositories)) { + res.status(400).json({ error: 'repositories array required', code: 'REPOSITORIES_ARRAY_REQUIRED' }); + return; + } + + const stmt = db.prepare(` + INSERT OR REPLACE INTO repositories ( + id, name, full_name, description, html_url, stargazers_count, language, + created_at, updated_at, pushed_at, starred_at, + owner_login, owner_avatar_url, topics, + ai_summary, ai_tags, ai_platforms, analyzed_at, analysis_failed, + custom_description, custom_tags, custom_category, last_edited, + subscribed_to_releases + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + const upsert = db.transaction(() => { + let count = 0; + for (const repo of repositories) { + const owner = repo.owner as { login?: string; avatar_url?: string } | undefined; + stmt.run( + repo.id, repo.name, repo.full_name, repo.description ?? null, + repo.html_url, repo.stargazers_count ?? 0, repo.language ?? null, + repo.created_at ?? null, repo.updated_at ?? null, repo.pushed_at ?? null, + repo.starred_at ?? null, + owner?.login ?? '', owner?.avatar_url ?? null, + JSON.stringify(repo.topics ?? []), + repo.ai_summary ?? null, + JSON.stringify(repo.ai_tags ?? []), + JSON.stringify(repo.ai_platforms ?? []), + repo.analyzed_at ?? null, repo.analysis_failed ? 1 : 0, + repo.custom_description ?? null, + JSON.stringify(repo.custom_tags ?? []), + repo.custom_category ?? null, repo.last_edited ?? null, + repo.subscribed_to_releases ? 1 : 0 + ); + count++; + } + return count; + }); + + const count = upsert(); + res.json({ upserted: count }); + } catch (err) { + console.error('PUT /api/repositories error:', err); + res.status(500).json({ error: 'Failed to upsert repositories', code: 'UPSERT_REPOSITORIES_FAILED' }); + } +}); + +// PATCH /api/repositories/:id +router.patch('/api/repositories/:id', (req, res) => { + try { + const db = getDb(); + const id = parseInt(req.params.id); + const updates = req.body as Record; + + const allowedFields: Record unknown> = { + ai_summary: (v) => v, + ai_tags: (v) => JSON.stringify(v), + ai_platforms: (v) => JSON.stringify(v), + analyzed_at: (v) => v, + analysis_failed: (v) => v ? 1 : 0, + custom_description: (v) => v, + custom_tags: (v) => JSON.stringify(v), + custom_category: (v) => v, + last_edited: (v) => v, + subscribed_to_releases: (v) => v ? 1 : 0, + description: (v) => v, + name: (v) => v, + }; + + const setClauses: string[] = []; + const values: unknown[] = []; + + for (const [key, transform] of Object.entries(allowedFields)) { + if (key in updates) { + setClauses.push(`${key} = ?`); + values.push(transform(updates[key])); + } + } + + if (setClauses.length === 0) { + res.status(400).json({ error: 'No valid fields to update', code: 'NO_VALID_FIELDS' }); + return; + } + + values.push(id); + db.prepare(`UPDATE repositories SET ${setClauses.join(', ')} WHERE id = ?`).run(...values); + + const row = db.prepare('SELECT * FROM repositories WHERE id = ?').get(id) as Record | undefined; + if (!row) { + res.status(404).json({ error: 'Repository not found', code: 'REPOSITORY_NOT_FOUND' }); + return; + } + res.json(transformRepo(row)); + } catch (err) { + console.error('PATCH /api/repositories error:', err); + res.status(500).json({ error: 'Failed to update repository', code: 'UPDATE_REPOSITORY_FAILED' }); + } +}); + +export default router; diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts new file mode 100644 index 0000000..ca019c2 --- /dev/null +++ b/server/src/routes/sync.ts @@ -0,0 +1,250 @@ +import { Router } from 'express'; +import { getDb } from '../db/connection.js'; +import { encrypt, decrypt } from '../services/crypto.js'; +import { config } from '../config.js'; + +const router = Router(); + +function maskApiKey(key: string | null | undefined): string { + if (!key || typeof key !== 'string') return ''; + if (key.length <= 4) return '****'; + return '***' + key.slice(-4); +} + +// POST /api/sync/export +router.post('/api/sync/export', (_req, res) => { + try { + const db = getDb(); + + const repositories = db.prepare('SELECT * FROM repositories').all() as Record[]; + const releases = db.prepare('SELECT * FROM releases').all() as Record[]; + const categories = db.prepare('SELECT * FROM categories').all() as Record[]; + const assetFilters = db.prepare('SELECT * FROM asset_filters').all() as Record[]; + + // AI configs — mask api_key + const aiConfigRows = db.prepare('SELECT * FROM ai_configs').all() as Record[]; + const aiConfigs = aiConfigRows.map((row) => { + const masked = { ...row }; + if (masked.api_key_encrypted && typeof masked.api_key_encrypted === 'string') { + try { + masked.api_key_masked = maskApiKey(decrypt(masked.api_key_encrypted, config.encryptionKey)); + } catch { + masked.api_key_masked = '****'; + } + } + delete masked.api_key_encrypted; + return masked; + }); + + // WebDAV configs — mask password + const webdavRows = db.prepare('SELECT * FROM webdav_configs').all() as Record[]; + const webdavConfigs = webdavRows.map((row) => { + const masked = { ...row }; + if (masked.password_encrypted && typeof masked.password_encrypted === 'string') { + try { + masked.password_masked = maskApiKey(decrypt(masked.password_encrypted, config.encryptionKey)); + } catch { + masked.password_masked = '****'; + } + } + delete masked.password_encrypted; + return masked; + }); + + // Settings — mask github_token + const settingsRows = db.prepare('SELECT * FROM settings').all() as Record[]; + const settings: Record = {}; + for (const row of settingsRows) { + const key = row.key as string; + let value = row.value as string | null; + if (key === 'github_token' && value) { + try { + value = maskApiKey(decrypt(value, config.encryptionKey)); + } catch { + value = '****'; + } + } + settings[key] = value; + } + + res.json({ + version: 1, + exported_at: new Date().toISOString(), + repositories, + releases, + categories, + asset_filters: assetFilters, + ai_configs: aiConfigs, + webdav_configs: webdavConfigs, + settings, + }); + } catch (err) { + console.error('POST /api/sync/export error:', err); + res.status(500).json({ error: 'Failed to export data', code: 'EXPORT_DATA_FAILED' }); + } +}); + +// POST /api/sync/import +router.post('/api/sync/import', (req, res) => { + try { + const db = getDb(); + const data = req.body as Record; + const counts: Record = {}; + + const importAll = db.transaction(() => { + // Repositories + const repos = data.repositories as Record[] | undefined; + if (Array.isArray(repos) && repos.length > 0) { + const repoStmt = db.prepare(` + INSERT OR REPLACE INTO repositories ( + id, name, full_name, description, html_url, stargazers_count, language, + created_at, updated_at, pushed_at, starred_at, + owner_login, owner_avatar_url, topics, + ai_summary, ai_tags, ai_platforms, analyzed_at, analysis_failed, + custom_description, custom_tags, custom_category, last_edited, + subscribed_to_releases + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const r of repos) { + repoStmt.run( + r.id, r.name, r.full_name, r.description ?? null, + r.html_url, r.stargazers_count ?? 0, r.language ?? null, + r.created_at ?? null, r.updated_at ?? null, r.pushed_at ?? null, + r.starred_at ?? null, + r.owner_login ?? '', r.owner_avatar_url ?? null, + typeof r.topics === 'string' ? r.topics : JSON.stringify(r.topics ?? []), + r.ai_summary ?? null, + typeof r.ai_tags === 'string' ? r.ai_tags : JSON.stringify(r.ai_tags ?? []), + typeof r.ai_platforms === 'string' ? r.ai_platforms : JSON.stringify(r.ai_platforms ?? []), + r.analyzed_at ?? null, r.analysis_failed ? 1 : 0, + r.custom_description ?? null, + typeof r.custom_tags === 'string' ? r.custom_tags : JSON.stringify(r.custom_tags ?? []), + r.custom_category ?? null, r.last_edited ?? null, + r.subscribed_to_releases ? 1 : 0 + ); + } + counts.repositories = repos.length; + } + + // Releases + const rels = data.releases as Record[] | undefined; + if (Array.isArray(rels) && rels.length > 0) { + const relStmt = db.prepare(` + INSERT OR REPLACE INTO releases ( + id, tag_name, name, body, html_url, published_at, + prerelease, draft, is_read, assets, + repo_id, repo_full_name, repo_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const r of rels) { + relStmt.run( + r.id, r.tag_name ?? null, r.name ?? null, r.body ?? null, + r.html_url ?? null, r.published_at ?? null, + r.prerelease ? 1 : 0, r.draft ? 1 : 0, r.is_read ? 1 : 0, + typeof r.assets === 'string' ? r.assets : JSON.stringify(r.assets ?? []), + r.repo_id ?? null, r.repo_full_name ?? null, r.repo_name ?? null + ); + } + counts.releases = rels.length; + } + + // Categories + const cats = data.categories as Record[] | undefined; + if (Array.isArray(cats) && cats.length > 0) { + const catStmt = db.prepare(` + INSERT OR REPLACE INTO categories (id, name, description, keywords, color, icon, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + for (const c of cats) { + catStmt.run( + c.id, c.name ?? '', c.description ?? null, + typeof c.keywords === 'string' ? c.keywords : JSON.stringify(c.keywords ?? []), + c.color ?? null, c.icon ?? null, c.sort_order ?? 0 + ); + } + counts.categories = cats.length; + } + + // Asset Filters + const filters = data.asset_filters as Record[] | undefined; + if (Array.isArray(filters) && filters.length > 0) { + const filterStmt = db.prepare(` + INSERT OR REPLACE INTO asset_filters (id, name, description, keywords, platform, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const f of filters) { + filterStmt.run( + f.id, f.name ?? '', f.description ?? null, + typeof f.keywords === 'string' ? f.keywords : JSON.stringify(f.keywords ?? []), + f.platform ?? null, f.sort_order ?? 0 + ); + } + counts.asset_filters = filters.length; + } + + // AI Configs — skip masked secrets + const aiConfigs = data.ai_configs as Record[] | undefined; + if (Array.isArray(aiConfigs) && aiConfigs.length > 0) { + for (const c of aiConfigs) { + const existing = db.prepare('SELECT api_key_encrypted FROM ai_configs WHERE id = ?').get(c.id) as Record | undefined; + const existingKey = (existing?.api_key_encrypted as string) ?? null; + // Skip masked keys, keep existing encrypted value + db.prepare(` + INSERT OR REPLACE INTO ai_configs (id, name, provider, model, base_url, api_key_encrypted, is_default, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + c.id, c.name ?? '', c.provider ?? '', c.model ?? '', c.base_url ?? null, + existingKey, + c.is_default ? 1 : 0, c.created_at ?? null, c.updated_at ?? null + ); + } + counts.ai_configs = aiConfigs.length; + } + + // WebDAV Configs — skip masked secrets + const webdavConfigs = data.webdav_configs as Record[] | undefined; + if (Array.isArray(webdavConfigs) && webdavConfigs.length > 0) { + for (const c of webdavConfigs) { + const existing = db.prepare('SELECT password_encrypted FROM webdav_configs WHERE id = ?').get(c.id) as Record | undefined; + const existingPwd = (existing?.password_encrypted as string) ?? null; + db.prepare(` + INSERT OR REPLACE INTO webdav_configs (id, name, url, username, password_encrypted, path, is_default, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + c.id, c.name ?? '', c.url ?? '', c.username ?? '', + existingPwd, + c.path ?? '/', c.is_default ? 1 : 0, c.created_at ?? null, c.updated_at ?? null + ); + } + counts.webdav_configs = webdavConfigs.length; + } + + // Settings — skip masked github_token + const settings = data.settings as Record | undefined; + if (settings && typeof settings === 'object') { + const settingsStmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + let settingsCount = 0; + for (const [key, value] of Object.entries(settings)) { + if (key === 'github_token' && typeof value === 'string' && value.startsWith('***')) { + continue; // Skip masked token + } + if (key === 'github_token' && value && typeof value === 'string') { + settingsStmt.run(key, encrypt(value, config.encryptionKey)); + } else { + settingsStmt.run(key, (value as string) ?? null); + } + settingsCount++; + } + counts.settings = settingsCount; + } + }); + + importAll(); + res.json({ imported: counts }); + } catch (err) { + console.error('POST /api/sync/import error:', err); + res.status(500).json({ error: 'Failed to import data', code: 'IMPORT_DATA_FAILED' }); + } +}); + +export default router; diff --git a/server/src/services/crypto.ts b/server/src/services/crypto.ts new file mode 100644 index 0000000..aa3f295 --- /dev/null +++ b/server/src/services/crypto.ts @@ -0,0 +1,46 @@ +import crypto from 'node:crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; + +export function encrypt(plaintext: string, key: string): string { + const keyBuffer = Buffer.from(key, 'hex'); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + return [ + iv.toString('base64'), + encrypted.toString('base64'), + authTag.toString('base64'), + ].join(':'); +} + +export function decrypt(encryptedStr: string, key: string): string { + const parts = encryptedStr.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted string format'); + } + + const [ivB64, ciphertextB64, authTagB64] = parts; + const keyBuffer = Buffer.from(key, 'hex'); + const iv = Buffer.from(ivB64, 'base64'); + const ciphertext = Buffer.from(ciphertextB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + + const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + + return decrypted.toString('utf8'); +} diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts new file mode 100644 index 0000000..9945858 --- /dev/null +++ b/server/src/services/proxyService.ts @@ -0,0 +1,63 @@ +export interface ProxyRequestOptions { + url: string; + method: string; + headers?: Record; + body?: string | object; + timeout?: number; +} + +export interface ProxyResponse { + status: number; + headers: Record; + data: unknown; +} + +export async function proxyRequest(options: ProxyRequestOptions): Promise { + const { url, method, headers = {}, body, timeout = 30000 } = options; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + console.log(`[Proxy] ${method} ${url}`); + + const fetchOptions: RequestInit = { + method, + headers, + signal: controller.signal, + }; + + if (body && method !== 'GET' && method !== 'HEAD') { + fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + if (!headers['Content-Type']) { + (fetchOptions.headers as Record)['Content-Type'] = 'application/json'; + } + } + + const response = await fetch(url, fetchOptions); + clearTimeout(timeoutId); + + console.log(`[Proxy] ${method} ${url} -> ${response.status}`); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + let data: unknown; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + data = await response.text(); + } + + return { status: response.status, headers: responseHeaders, data }; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof Error && error.name === 'AbortError') { + return { status: 504, headers: {}, data: { error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' } }; + } + console.error(`[Proxy] Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + return { status: 502, headers: {}, data: { error: 'Bad Gateway', code: 'BAD_GATEWAY', details: error instanceof Error ? error.message : 'Unknown error' } }; + } +} diff --git a/server/tests/routes/proxy.test.ts b/server/tests/routes/proxy.test.ts new file mode 100644 index 0000000..4223233 --- /dev/null +++ b/server/tests/routes/proxy.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { proxyRequest } from '../../src/services/proxyService.js'; + +// Store original fetch +const originalFetch = globalThis.fetch; + +describe('proxyRequest', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('should forward GET request and return JSON response', async () => { + const responseData = { items: [1, 2, 3] }; + const headers = new Headers({ 'content-type': 'application/json', 'x-ratelimit': '100' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), + }); + + const result = await proxyRequest({ + url: 'https://api.github.com/user/starred', + method: 'GET', + headers: { 'Authorization': 'Bearer test-token' }, + }); + + expect(result.status).toBe(200); + expect(result.data).toEqual(responseData); + expect(result.headers['content-type']).toBe('application/json'); + expect(result.headers['x-ratelimit']).toBe('100'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [calledUrl, calledOptions] = mockFetch.mock.calls[0]; + expect(calledUrl).toBe('https://api.github.com/user/starred'); + expect(calledOptions.method).toBe('GET'); + expect(calledOptions.headers['Authorization']).toBe('Bearer test-token'); + expect(calledOptions.body).toBeUndefined(); + }); + + it('should forward POST request with JSON body', async () => { + const requestBody = { model: 'gpt-4', messages: [{ role: 'user', content: 'hello' }] }; + const responseData = { choices: [{ message: { content: 'hi' } }] }; + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve(responseData), + text: () => Promise.resolve(JSON.stringify(responseData)), + }); + + const result = await proxyRequest({ + url: 'https://api.openai.com/v1/chat/completions', + method: 'POST', + headers: { 'Authorization': 'Bearer sk-test', 'Content-Type': 'application/json' }, + body: requestBody, + }); + + expect(result.status).toBe(200); + expect(result.data).toEqual(responseData); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.method).toBe('POST'); + expect(calledOptions.body).toBe(JSON.stringify(requestBody)); + }); + + it('should forward POST request with string body', async () => { + const xmlBody = ''; + const headers = new Headers({ 'content-type': 'application/xml' }); + mockFetch.mockResolvedValueOnce({ + status: 207, + headers, + json: () => Promise.reject(new Error('not json')), + text: () => Promise.resolve(''), + }); + + const result = await proxyRequest({ + url: 'https://dav.example.com/remote.php/dav', + method: 'PROPFIND', + headers: { 'Content-Type': 'application/xml' }, + body: xmlBody, + }); + + expect(result.status).toBe(207); + expect(result.data).toBe(''); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.body).toBe(xmlBody); + }); + + it('should auto-set Content-Type to application/json when body is object and no Content-Type header', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve('{"ok":true}'), + }); + + await proxyRequest({ + url: 'https://example.com/api', + method: 'POST', + body: { key: 'value' }, + }); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect((calledOptions.headers as Record)['Content-Type']).toBe('application/json'); + }); + + it('should NOT attach body for GET requests', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve([]), + text: () => Promise.resolve('[]'), + }); + + await proxyRequest({ + url: 'https://api.github.com/repos', + method: 'GET', + body: { should: 'be-ignored' }, + }); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.body).toBeUndefined(); + }); + + it('should NOT attach body for HEAD requests', async () => { + const headers = new Headers({}); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve(null), + text: () => Promise.resolve(''), + }); + + await proxyRequest({ + url: 'https://example.com/check', + method: 'HEAD', + body: 'ignored', + }); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.body).toBeUndefined(); + }); + + it('should return text data when response is not JSON', async () => { + const headers = new Headers({ 'content-type': 'text/html' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.reject(new Error('not json')), + text: () => Promise.resolve('hello'), + }); + + const result = await proxyRequest({ + url: 'https://example.com/page', + method: 'GET', + }); + + expect(result.status).toBe(200); + expect(result.data).toBe('hello'); + }); + + it('should return 504 on timeout (AbortError)', async () => { + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + mockFetch.mockRejectedValueOnce(abortError); + + const result = await proxyRequest({ + url: 'https://slow.example.com/api', + method: 'GET', + timeout: 100, + }); + + expect(result.status).toBe(504); + expect(result.data).toEqual({ error: 'Gateway Timeout', code: 'GATEWAY_TIMEOUT' }); + expect(result.headers).toEqual({}); + }); + + it('should return 502 on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const result = await proxyRequest({ + url: 'https://down.example.com/api', + method: 'GET', + }); + + expect(result.status).toBe(502); + expect(result.data).toEqual({ + error: 'Bad Gateway', + code: 'BAD_GATEWAY', + details: 'ECONNREFUSED', + }); + expect(result.headers).toEqual({}); + }); + + it('should pass abort signal to fetch', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve({ ok: true }), + text: () => Promise.resolve('{"ok":true}'), + }); + + await proxyRequest({ + url: 'https://example.com/api', + method: 'GET', + timeout: 5000, + }); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.signal).toBeInstanceOf(AbortSignal); + }); + + it('should handle upstream 4xx/5xx status codes transparently', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 403, + headers, + json: () => Promise.resolve({ message: 'Forbidden' }), + text: () => Promise.resolve('{"message":"Forbidden"}'), + }); + + const result = await proxyRequest({ + url: 'https://api.github.com/forbidden', + method: 'GET', + }); + + expect(result.status).toBe(403); + expect(result.data).toEqual({ message: 'Forbidden' }); + }); + + it('should use default timeout of 30000ms', async () => { + const headers = new Headers({ 'content-type': 'application/json' }); + mockFetch.mockResolvedValueOnce({ + status: 200, + headers, + json: () => Promise.resolve({}), + text: () => Promise.resolve('{}'), + }); + + // Just verify it doesn't throw — the default timeout is internal + const result = await proxyRequest({ + url: 'https://example.com/api', + method: 'GET', + }); + + expect(result.status).toBe(200); + }); + + it('should handle PUT method with body for WebDAV', async () => { + const headers = new Headers({ 'content-type': 'text/plain' }); + mockFetch.mockResolvedValueOnce({ + status: 201, + headers, + json: () => Promise.reject(new Error('not json')), + text: () => Promise.resolve(''), + }); + + const result = await proxyRequest({ + url: 'https://dav.example.com/remote.php/dav/backup.json', + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: '{"repos":[]}', + }); + + expect(result.status).toBe(201); + + const [, calledOptions] = mockFetch.mock.calls[0]; + expect(calledOptions.method).toBe('PUT'); + expect(calledOptions.body).toBe('{"repos":[]}'); + }); +}); diff --git a/server/tests/services/crypto.test.ts b/server/tests/services/crypto.test.ts new file mode 100644 index 0000000..9e51dc7 --- /dev/null +++ b/server/tests/services/crypto.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import crypto from 'node:crypto'; +import { encrypt, decrypt } from '../../src/services/crypto.js'; + +// Generate a test key (32-byte hex = 64 hex chars) +const testKey = crypto.randomBytes(32).toString('hex'); + +describe('Crypto Service', () => { + it('should encrypt and decrypt back to original', () => { + const plaintext = 'sk-my-super-secret-api-key-12345'; + const encrypted = encrypt(plaintext, testKey); + const decrypted = decrypt(encrypted, testKey); + expect(decrypted).toBe(plaintext); + }); + + it('should produce different ciphertexts for same plaintext (random IV)', () => { + const plaintext = 'same-plaintext'; + const encrypted1 = encrypt(plaintext, testKey); + const encrypted2 = encrypt(plaintext, testKey); + expect(encrypted1).not.toBe(encrypted2); + + // But both should decrypt to the same value + expect(decrypt(encrypted1, testKey)).toBe(plaintext); + expect(decrypt(encrypted2, testKey)).toBe(plaintext); + }); + + it('should throw error when decrypting with wrong key', () => { + const plaintext = 'secret-data'; + const encrypted = encrypt(plaintext, testKey); + const wrongKey = crypto.randomBytes(32).toString('hex'); + + expect(() => decrypt(encrypted, wrongKey)).toThrow(); + }); + + it('should throw error when ciphertext is tampered', () => { + const plaintext = 'important-secret'; + const encrypted = encrypt(plaintext, testKey); + + // Tamper with the ciphertext part + const parts = encrypted.split(':'); + const ciphertextBuf = Buffer.from(parts[1], 'base64'); + ciphertextBuf[0] = ciphertextBuf[0] ^ 0xff; // flip bits + parts[1] = ciphertextBuf.toString('base64'); + const tampered = parts.join(':'); + + expect(() => decrypt(tampered, testKey)).toThrow(); + }); + + it('should throw error for invalid encrypted string format', () => { + expect(() => decrypt('not-a-valid-format', testKey)).toThrow('Invalid encrypted string format'); + }); + + it('should handle empty string', () => { + const plaintext = ''; + const encrypted = encrypt(plaintext, testKey); + const decrypted = decrypt(encrypted, testKey); + expect(decrypted).toBe(plaintext); + }); + + it('should handle unicode content', () => { + const plaintext = '你好世界 🌍 héllo'; + const encrypted = encrypt(plaintext, testKey); + const decrypted = decrypt(encrypted, testKey); + expect(decrypted).toBe(plaintext); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..391ca78 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 0000000..68f9768 --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + root: './', + test: { + globals: false, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + css: false, +}); diff --git a/src/App.tsx b/src/App.tsx index c21c4af..262da90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { SettingsPanel } from './components/SettingsPanel'; import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; +import { backend } from './services/backendAdapter'; function App() { const { @@ -33,6 +34,11 @@ function App() { } }, [theme]); + // Initialize backend adapter + useEffect(() => { + backend.init(); + }, []); + // Show login screen if not authenticated if (!isAuthenticated) { return ; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index f8605c2..9067425 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Bot, Plus, @@ -19,13 +19,15 @@ import { ExternalLink, Mail, Github, - Twitter + Twitter, + Server, } from 'lucide-react'; import { AIConfig, WebDAVConfig } from '../types'; import { useAppStore } from '../store/useAppStore'; import { AIService } from '../services/aiService'; import { WebDAVService } from '../services/webdavService'; import { UpdateChecker } from './UpdateChecker'; +import { backend } from '../services/backendAdapter'; export const SettingsPanel: React.FC = () => { const { @@ -53,6 +55,8 @@ export const SettingsPanel: React.FC = () => { setReleases, addCustomCategory, deleteCustomCategory, + backendApiSecret, + setBackendApiSecret, } = useAppStore(); const [showAIForm, setShowAIForm] = useState(false); @@ -64,6 +68,27 @@ export const SettingsPanel: React.FC = () => { const [isBackingUp, setIsBackingUp] = useState(false); const [isRestoring, setIsRestoring] = useState(false); const [showCustomPrompt, setShowCustomPrompt] = useState(false); + const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('disconnected'); + const [backendHealth, setBackendHealth] = useState<{ version: string; timestamp: string } | null>(null); + const [isSyncingToBackend, setIsSyncingToBackend] = useState(false); + const [isSyncingFromBackend, setIsSyncingFromBackend] = useState(false); + const [backendSecretInput, setBackendSecretInput] = useState(backendApiSecret || ''); + + // Check backend status on mount + useEffect(() => { + const checkBackend = async () => { + setBackendStatus('checking'); + const health = await backend.checkHealth(); + if (health) { + setBackendStatus('connected'); + setBackendHealth({ version: health.version, timestamp: health.timestamp }); + } else { + setBackendStatus('disconnected'); + setBackendHealth(null); + } + }; + checkBackend(); + }, []); type AIFormState = { name: string; @@ -472,6 +497,81 @@ Focus on practicality and accurate categorization to help users quickly understa const t = (zh: string, en: string) => language === 'zh' ? zh : en; + const handleTestBackendConnection = async () => { + setBackendStatus('checking'); + // Save the secret first + setBackendApiSecret(backendSecretInput || null); + // Re-init and check + await backend.init(); + const health = await backend.checkHealth(); + if (health) { + setBackendStatus('connected'); + setBackendHealth({ version: health.version, timestamp: health.timestamp }); + alert(t('后端连接成功!', 'Backend connection successful!')); + } else { + setBackendStatus('disconnected'); + setBackendHealth(null); + alert(t('后端连接失败,请检查服务器是否运行。', 'Backend connection failed. Please check if the server is running.')); + } + }; + + const handleSyncToBackend = async () => { + if (!backend.isAvailable) { + alert(t('后端不可用', 'Backend not available')); + return; + } + setIsSyncingToBackend(true); + try { + await backend.syncRepositories(repositories); + await backend.syncReleases(releases); + alert(t( + `已同步到后端:仓库 ${repositories.length},发布 ${releases.length}`, + `Synced to backend: repositories ${repositories.length}, releases ${releases.length}` + )); + } catch (error) { + console.error('Sync to backend failed:', error); + alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`); + } finally { + setIsSyncingToBackend(false); + } + }; + + const handleSyncFromBackend = async () => { + if (!backend.isAvailable) { + alert(t('后端不可用', 'Backend not available')); + return; + } + + if (!confirm(t( + '从后端同步将覆盖本地数据,是否继续?', + 'Syncing from backend will overwrite local data. Continue?' + ))) return; + + setIsSyncingFromBackend(true); + try { + const repoData = await backend.fetchRepositories(); + const releaseData = await backend.fetchReleases(); + + if (repoData.repositories.length > 0) { + setRepositories(repoData.repositories); + } + if (releaseData.releases.length > 0) { + setReleases(releaseData.releases); + } + + alert(t( + `已从后端同步:仓库 ${repoData.repositories.length},发布 ${releaseData.releases.length}`, + `Synced from backend: repositories ${repoData.repositories.length}, releases ${releaseData.releases.length}` + )); + } catch (error) { + console.error('Sync from backend failed:', error); + alert(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`); + } finally { + setIsSyncingFromBackend(false); + } + }; + + return (
{/* Update Check */} @@ -1066,6 +1166,100 @@ Focus on practicality and accurate categorization to help users quickly understa
)} + {/* Backend Server Configuration */} +
+
+
+ +

+ {t('后端服务器', 'Backend Server')} +

+ + {backendStatus === 'connected' ? '🟢 ' + t('已连接', 'Connected') + : backendStatus === 'checking' ? '🟡 ' + t('检查中...', 'Checking...') + : '🔴 ' + t('未连接', 'Not Connected')} + +
+
+ + {backendHealth && ( +
+

{t('版本', 'Version')}: {backendHealth.version}

+
+ )} + +
+
+ +
+ setBackendSecretInput(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + placeholder={t('输入后端 API_SECRET(可选)', 'Enter backend API_SECRET (optional)')} + /> + +
+

+ {t( + '如果后端设置了 API_SECRET 环境变量,在此输入相同的值。未设置则留空。', + 'If the backend has API_SECRET env var set, enter the same value here. Leave empty if not set.' + )} +

+
+ + {backend.isAvailable && ( +
+ + + +
+ )} +
+
+ ); }; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts new file mode 100644 index 0000000..0301b53 --- /dev/null +++ b/src/services/backendAdapter.ts @@ -0,0 +1,273 @@ +import { translateBackendError } from '../utils/backendErrors'; + +import { Repository, Release } from '../types'; + +class BackendAdapter { + private _backendUrl: string | null = null; + + async init(): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + // Try common backend URLs + const urls = [ + window.location.origin + '/api', + 'http://localhost:3000/api', + ]; + + for (const baseUrl of urls) { + try { + const res = await fetch(`${baseUrl}/health`, { + signal: controller.signal, + }); + + if (res.ok) { + const data = await res.json(); + if (data.status === 'ok') { + this._backendUrl = baseUrl; + console.log(`✅ Backend connected: ${baseUrl}`); + clearTimeout(timeoutId); + return; + } + } + } catch { + // Try next URL + } + } + + clearTimeout(timeoutId); + this._backendUrl = null; + console.log('ℹ️ Backend not available, using local-only mode'); + } catch { + this._backendUrl = null; + console.log('ℹ️ Backend not available, using local-only mode'); + } + } + + get isAvailable(): boolean { + return this._backendUrl !== null; + } + + get backendUrl(): string | null { + return this._backendUrl; + } + + private getAuthHeaders(): Record { + // Read from localStorage directly to avoid circular dependency with store + const storeData = localStorage.getItem('github-stars-manager'); + let secret = ''; + if (storeData) { + try { + const parsed = JSON.parse(storeData); + secret = parsed.state?.backendApiSecret || ''; + } catch { /* ignore */ } + } + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (secret) { + headers['Authorization'] = `Bearer ${secret}`; + } + return headers; + } + private async throwTranslatedError(res: Response, fallbackPrefix: string): Promise { + let code: string | undefined; + try { + const data = await res.json(); + code = data.code; + } catch { /* body not JSON */ } + throw new Error(translateBackendError(code, `${fallbackPrefix}: ${res.status}`)); + } + + // === GitHub Proxy === + + async fetchStarredRepos(page = 1, perPage = 100): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/proxy/github/user/starred?page=${page}&per_page=${perPage}&sort=updated`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ + method: 'GET', + headers: { 'Accept': 'application/vnd.github.star+json' } + }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Backend proxy error'); + const data = await res.json(); + return (data as Record[]).map((item) => + (item as { starred_at?: string; repo?: Repository }).starred_at && (item as { repo?: Repository }).repo + ? { ...((item as { repo: Repository }).repo), starred_at: (item as { starred_at: string }).starred_at } + : item as unknown as Repository + ); + } + + async getCurrentUser(): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/proxy/github/user`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ method: 'GET' }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Backend proxy error'); + return res.json() as Promise>; + } + + async getRepositoryReadme(owner: string, repo: string): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + try { + const res = await fetch(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/readme`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ method: 'GET' }) + }); + if (!res.ok) return ''; + const data = await res.json() as { encoding?: string; content?: string }; + if (data.encoding === 'base64' && data.content) { + return atob(data.content); + } + return data.content || ''; + } catch { + return ''; + } + } + + async getRepositoryReleases(owner: string, repo: string, page = 1, perPage = 30): Promise[]> { + if (!this._backendUrl) throw new Error('Backend not available'); + + try { + const res = await fetch(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ method: 'GET' }) + }); + if (!res.ok) return []; + return res.json() as Promise[]>; + } catch { + return []; + } + } + + async checkRateLimit(): Promise<{ remaining: number; reset: number }> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/proxy/github/rate_limit`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ method: 'GET' }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Backend proxy error'); + const data = await res.json() as { rate: { remaining: number; reset: number } }; + return { remaining: data.rate.remaining, reset: data.rate.reset }; + } + + // === AI Proxy === + + async proxyAIRequest(configId: string, body: object): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/proxy/ai`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ configId, body }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error'); + return res.json(); + } + + // === WebDAV Proxy === + + async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + return fetch(`${this._backendUrl}/proxy/webdav`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ configId, method, path, body, headers }) + }); + } + + // === Data Sync === + + async syncRepositories(repos: Repository[]): Promise { + if (!this._backendUrl) return; + + await fetch(`${this._backendUrl}/repositories`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify({ repositories: repos }) + }); + } + + async fetchRepositories(): Promise<{ repositories: Repository[]; total: number }> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/repositories?limit=10000`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); + return res.json() as Promise<{ repositories: Repository[]; total: number }>; + } + + async syncReleases(releases: Release[]): Promise { + if (!this._backendUrl) return; + + await fetch(`${this._backendUrl}/releases`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify({ releases }) + }); + } + + async fetchReleases(): Promise<{ releases: Release[]; total: number }> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/releases?limit=10000`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); + return res.json() as Promise<{ releases: Release[]; total: number }>; + } + + async exportData(): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/sync/export`, { + method: 'POST', + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Export error'); + return res.json() as Promise>; + } + + async importData(data: Record): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/sync/import`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify(data) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Import error'); + return res.json() as Promise>; + } + + // === Health === + + async checkHealth(): Promise<{ status: string; version: string; timestamp: string } | null> { + if (!this._backendUrl) return null; + + try { + const res = await fetch(`${this._backendUrl}/health`); + if (res.ok) return res.json() as Promise<{ status: string; version: string; timestamp: string }>; + return null; + } catch { + return null; + } + } +} + +export const backend = new BackendAdapter(); diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 7daeae1..26e5b01 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -59,6 +59,9 @@ interface AppActions { // Update Analysis Progress setAnalysisProgress: (newProgress: AnalysisProgress) => void; + + // Backend actions + setBackendApiSecret: (secret: string | null) => void; } const initialSearchFilters: SearchFilters = { @@ -186,6 +189,7 @@ export const useAppStore = create()( language: 'zh', updateNotification: null, analysisProgress: { current: 0, total: 0 }, + backendApiSecret: null, // Auth actions setUser: (user) => { @@ -320,7 +324,8 @@ export const useAppStore = create()( // Update actions setUpdateNotification: (notification) => set({ updateNotification: notification }), dismissUpdateNotification: () => set({ updateNotification: null }), - setAnalysisProgress: (newProgress) => set({ analysisProgress: newProgress }) + setAnalysisProgress: (newProgress) => set({ analysisProgress: newProgress }), + setBackendApiSecret: (backendApiSecret) => set({ backendApiSecret }), }), { name: 'github-stars-manager', @@ -357,6 +362,9 @@ export const useAppStore = create()( // 持久化UI设置 theme: state.theme, language: state.language, + + // 持久化后端设置 + backendApiSecret: state.backendApiSecret, // 持久化搜索排序设置 searchFilters: { diff --git a/src/types/index.ts b/src/types/index.ts index 02677cd..cb5815d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -161,6 +161,9 @@ export interface AppState { // Analysis Progress analysisProgress: AnalysisProgress + + // Backend + backendApiSecret: string | null; } export interface UpdateNotification { diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts new file mode 100644 index 0000000..947f714 --- /dev/null +++ b/src/utils/backendErrors.ts @@ -0,0 +1,77 @@ +const ERROR_MESSAGES: Record = { + // Auth + UNAUTHORIZED: { zh: '未授权,请检查 API Secret', en: 'Unauthorized, please check API Secret' }, + // Proxy + GITHUB_TOKEN_NOT_CONFIGURED: { zh: 'GitHub Token 未配置', en: 'GitHub token not configured' }, + GITHUB_TOKEN_DECRYPT_FAILED: { zh: 'GitHub Token 解密失败', en: 'Failed to decrypt GitHub token' }, + GITHUB_PROXY_FAILED: { zh: 'GitHub 代理请求失败', en: 'GitHub proxy failed' }, + CONFIG_ID_REQUIRED: { zh: '缺少配置 ID', en: 'Config ID required' }, + AI_CONFIG_NOT_FOUND: { zh: 'AI 配置未找到', en: 'AI config not found' }, + AI_PROXY_FAILED: { zh: 'AI 代理请求失败', en: 'AI proxy failed' }, + WEBDAV_CONFIG_NOT_FOUND: { zh: 'WebDAV 配置未找到', en: 'WebDAV config not found' }, + WEBDAV_PROXY_FAILED: { zh: 'WebDAV 代理请求失败', en: 'WebDAV proxy failed' }, + GATEWAY_TIMEOUT: { zh: '网关超时', en: 'Gateway Timeout' }, + BAD_GATEWAY: { zh: '网关错误', en: 'Bad Gateway' }, + // Repositories + FETCH_REPOSITORIES_FAILED: { zh: '获取仓库列表失败', en: 'Failed to fetch repositories' }, + REPOSITORIES_ARRAY_REQUIRED: { zh: '需要仓库数组', en: 'Repositories array required' }, + UPSERT_REPOSITORIES_FAILED: { zh: '更新仓库失败', en: 'Failed to upsert repositories' }, + NO_VALID_FIELDS: { zh: '没有有效的更新字段', en: 'No valid fields to update' }, + REPOSITORY_NOT_FOUND: { zh: '仓库未找到', en: 'Repository not found' }, + UPDATE_REPOSITORY_FAILED: { zh: '更新仓库失败', en: 'Failed to update repository' }, + // Releases + FETCH_RELEASES_FAILED: { zh: '获取发布列表失败', en: 'Failed to fetch releases' }, + RELEASES_ARRAY_REQUIRED: { zh: '需要发布数组', en: 'Releases array required' }, + UPSERT_RELEASES_FAILED: { zh: '更新发布信息失败', en: 'Failed to upsert releases' }, + IS_READ_REQUIRED: { zh: '需要 is_read 字段', en: 'is_read field required' }, + RELEASE_NOT_FOUND: { zh: '发布未找到', en: 'Release not found' }, + UPDATE_RELEASE_FAILED: { zh: '更新发布失败', en: 'Failed to update release' }, + MARK_ALL_READ_FAILED: { zh: '标记全部已读失败', en: 'Failed to mark all as read' }, + // Categories + FETCH_CATEGORIES_FAILED: { zh: '获取分类失败', en: 'Failed to fetch categories' }, + CREATE_CATEGORY_FAILED: { zh: '创建分类失败', en: 'Failed to create category' }, + CATEGORY_NOT_FOUND: { zh: '分类未找到', en: 'Category not found' }, + UPDATE_CATEGORY_FAILED: { zh: '更新分类失败', en: 'Failed to update category' }, + DELETE_CATEGORY_FAILED: { zh: '删除分类失败', en: 'Failed to delete category' }, + // Asset filters + FETCH_ASSET_FILTERS_FAILED: { zh: '获取资源过滤器失败', en: 'Failed to fetch asset filters' }, + CREATE_ASSET_FILTER_FAILED: { zh: '创建资源过滤器失败', en: 'Failed to create asset filter' }, + ASSET_FILTER_NOT_FOUND: { zh: '资源过滤器未找到', en: 'Asset filter not found' }, + UPDATE_ASSET_FILTER_FAILED: { zh: '更新资源过滤器失败', en: 'Failed to update asset filter' }, + DELETE_ASSET_FILTER_FAILED: { zh: '删除资源过滤器失败', en: 'Failed to delete asset filter' }, + // Configs + FETCH_AI_CONFIGS_FAILED: { zh: '获取 AI 配置失败', en: 'Failed to fetch AI configs' }, + CREATE_AI_CONFIG_FAILED: { zh: '创建 AI 配置失败', en: 'Failed to create AI config' }, + UPDATE_AI_CONFIG_FAILED: { zh: '更新 AI 配置失败', en: 'Failed to update AI config' }, + DELETE_AI_CONFIG_FAILED: { zh: '删除 AI 配置失败', en: 'Failed to delete AI config' }, + FETCH_WEBDAV_CONFIGS_FAILED: { zh: '获取 WebDAV 配置失败', en: 'Failed to fetch WebDAV configs' }, + CREATE_WEBDAV_CONFIG_FAILED: { zh: '创建 WebDAV 配置失败', en: 'Failed to create WebDAV config' }, + UPDATE_WEBDAV_CONFIG_FAILED: { zh: '更新 WebDAV 配置失败', en: 'Failed to update WebDAV config' }, + DELETE_WEBDAV_CONFIG_FAILED: { zh: '删除 WebDAV 配置失败', en: 'Failed to delete WebDAV config' }, + FETCH_SETTINGS_FAILED: { zh: '获取设置失败', en: 'Failed to fetch settings' }, + UPDATE_SETTINGS_FAILED: { zh: '更新设置失败', en: 'Failed to update settings' }, + // Sync + EXPORT_DATA_FAILED: { zh: '导出数据失败', en: 'Failed to export data' }, + IMPORT_DATA_FAILED: { zh: '导入数据失败', en: 'Failed to import data' }, + // General + INTERNAL_SERVER_ERROR: { zh: '服务器内部错误', en: 'Internal server error' }, +}; + +function getCurrentLanguage(): 'zh' | 'en' { + try { + const storeData = localStorage.getItem('github-stars-manager'); + if (storeData) { + const parsed = JSON.parse(storeData); + return parsed.state?.language || 'zh'; + } + } catch { /* ignore */ } + return 'zh'; +} + +export function translateBackendError(code: string | undefined, fallback: string): string { + if (!code) return fallback; + const entry = ERROR_MESSAGES[code]; + if (!entry) return fallback; + const lang = getCurrentLanguage(); + return entry[lang]; +} From 737b9835b8ab2ae6368143dc7f9842693be32b6a Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 07:29:03 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20nginx=20?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE=E9=81=BF=E5=85=8D=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E9=85=8D=E7=BD=AE=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 011f1ad..db3976f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,9 @@ RUN npm run build # Production stage FROM nginx:alpine +# Remove default nginx config that conflicts with ours +RUN rm -f /etc/nginx/conf.d/default.conf + # Copy built files from build stage COPY --from=build /app/dist /usr/share/nginx/html From 0f198d724d673d954499b8c0919965738f6c9e28 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 07:46:23 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20AI/WebDAV=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=8C=E6=AD=A5=E5=88=B0=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 修复 configs.ts 列名错误 (provider→api_type, is_default→is_active, 移除不存在的 created_at/updated_at) - 后端: 添加 PUT /api/configs/ai/bulk 和 PUT /api/configs/webdav/bulk 批量同步端点 - 后端: GET 端点支持 ?decrypt=true 返回完整密钥 - 后端: 修复 sync.ts import 中相同的列名错误和缺失字段 - 前端: backendAdapter 添加 syncAIConfigs/fetchAIConfigs/syncWebDAVConfigs/fetchWebDAVConfigs - 前端: store 添加 setAIConfigs/setWebDAVConfigs 批量设置方法 - 前端: SettingsPanel 同步按钮现在同时同步仓库、发布、AI配置和WebDAV配置 --- dist/index.html | 4 +- server/src/routes/configs.ts | 165 ++++++++++++++++++++++++------- server/src/routes/sync.ts | 32 +++--- src/components/SettingsPanel.tsx | 20 +++- src/services/backendAdapter.ts | 45 ++++++++- src/store/useAppStore.ts | 4 + 6 files changed, 210 insertions(+), 60 deletions(-) diff --git a/dist/index.html b/dist/index.html index ebac633..8208cf9 100644 --- a/dist/index.html +++ b/dist/index.html @@ -10,8 +10,8 @@ - - + +
diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index bab746f..e77a7aa 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -14,9 +14,10 @@ function maskApiKey(key: string | null | undefined): string { } // GET /api/configs/ai -router.get('/api/configs/ai', (_req, res) => { +router.get('/api/configs/ai', (req, res) => { try { const db = getDb(); + const shouldDecrypt = req.query.decrypt === 'true'; const rows = db.prepare('SELECT * FROM ai_configs ORDER BY id ASC').all() as Record[]; const configs = rows.map((row) => { let decryptedKey = ''; @@ -28,13 +29,14 @@ router.get('/api/configs/ai', (_req, res) => { return { id: row.id, name: row.name, - provider: row.provider, + apiType: row.api_type, model: row.model, - base_url: row.base_url, - api_key: maskApiKey(decryptedKey), - is_default: !!row.is_default, - created_at: row.created_at, - updated_at: row.updated_at, + baseUrl: row.base_url, + apiKey: shouldDecrypt ? decryptedKey : maskApiKey(decryptedKey), + isActive: !!row.is_active, + customPrompt: row.custom_prompt ?? null, + useCustomPrompt: !!row.use_custom_prompt, + concurrency: row.concurrency ?? 1, }; }); res.json(configs); @@ -48,31 +50,79 @@ router.get('/api/configs/ai', (_req, res) => { router.post('/api/configs/ai', (req, res) => { try { const db = getDb(); - const { name, provider, model, base_url, apiKey, is_default } = req.body as Record; + const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency } = req.body as Record; const encryptedKey = apiKey && typeof apiKey === 'string' ? encrypt(apiKey, config.encryptionKey) : null; - const now = new Date().toISOString(); const result = db.prepare( - 'INSERT INTO ai_configs (name, provider, model, base_url, api_key_encrypted, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO ai_configs (name, api_type, model, base_url, api_key_encrypted, is_active, custom_prompt, use_custom_prompt, concurrency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( - name ?? '', provider ?? '', model ?? '', base_url ?? null, - encryptedKey, is_default ? 1 : 0, now, now + name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, + encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1 ); - res.status(201).json({ id: result.lastInsertRowid, name, provider, model, base_url, api_key: maskApiKey(apiKey as string), is_default: !!is_default }); + res.status(201).json({ id: result.lastInsertRowid, name, apiType, model, baseUrl, apiKey: maskApiKey(apiKey as string), isActive: !!isActive }); } catch (err) { console.error('POST /api/configs/ai error:', err); res.status(500).json({ error: 'Failed to create AI config', code: 'CREATE_AI_CONFIG_FAILED' }); } }); +// PUT /api/configs/ai/bulk — replace all AI configs (for sync) +// MUST be registered before :id route to avoid matching 'bulk' as an id +router.put('/api/configs/ai/bulk', (req, res) => { + try { + const db = getDb(); + const configs = req.body.configs as Array<{ + id: string; + name: string; + apiType?: string; + baseUrl: string; + apiKey: string; + model: string; + isActive: boolean; + customPrompt?: string; + useCustomPrompt?: boolean; + concurrency?: number; + }>; + + if (!Array.isArray(configs)) { + res.status(400).json({ error: 'configs array required', code: 'INVALID_REQUEST' }); + return; + } + + const bulkSync = db.transaction(() => { + db.prepare('DELETE FROM ai_configs').run(); + + const stmt = db.prepare(` + INSERT INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const c of configs) { + const encryptedKey = c.apiKey ? encrypt(c.apiKey, config.encryptionKey) : ''; + stmt.run( + c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', + encryptedKey, c.model ?? '', c.isActive ? 1 : 0, + c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1 + ); + } + }); + + bulkSync(); + res.json({ synced: configs.length }); + } catch (err) { + console.error('PUT /api/configs/ai/bulk error:', err); + res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); + } +}); + // PUT /api/configs/ai/:id router.put('/api/configs/ai/:id', (req, res) => { try { const db = getDb(); - const id = parseInt(req.params.id); - const { name, provider, model, base_url, apiKey, is_default } = req.body as Record; + const id = req.params.id; + const { name, apiType, model, baseUrl, apiKey, isActive, customPrompt, useCustomPrompt, concurrency } = req.body as Record; let encryptedKey: string | null = null; if (apiKey && typeof apiKey === 'string' && !apiKey.startsWith('***')) { @@ -83,17 +133,16 @@ router.put('/api/configs/ai/:id', (req, res) => { encryptedKey = (existing?.api_key_encrypted as string) ?? null; } - const now = new Date().toISOString(); db.prepare( - 'UPDATE ai_configs SET name = ?, provider = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_default = ?, updated_at = ? WHERE id = ?' - ).run(name ?? '', provider ?? '', model ?? '', base_url ?? null, encryptedKey, is_default ? 1 : 0, now, id); + 'UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ? WHERE id = ?' + ).run(name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, id); let maskedKey = ''; if (encryptedKey) { try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } } - res.json({ id, name, provider, model, base_url, api_key: maskedKey, is_default: !!is_default }); + res.json({ id, name, apiType, model, baseUrl, apiKey: maskedKey, isActive: !!isActive }); } catch (err) { console.error('PUT /api/configs/ai error:', err); res.status(500).json({ error: 'Failed to update AI config', code: 'UPDATE_AI_CONFIG_FAILED' }); @@ -104,7 +153,7 @@ router.put('/api/configs/ai/:id', (req, res) => { router.delete('/api/configs/ai/:id', (req, res) => { try { const db = getDb(); - const id = parseInt(req.params.id); + const id = req.params.id; const result = db.prepare('DELETE FROM ai_configs WHERE id = ?').run(id); if (result.changes === 0) { res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); @@ -126,9 +175,10 @@ function maskPassword(pwd: string | null | undefined): string { } // GET /api/configs/webdav -router.get('/api/configs/webdav', (_req, res) => { +router.get('/api/configs/webdav', (req, res) => { try { const db = getDb(); + const shouldDecrypt = req.query.decrypt === 'true'; const rows = db.prepare('SELECT * FROM webdav_configs ORDER BY id ASC').all() as Record[]; const configs = rows.map((row) => { let decryptedPwd = ''; @@ -142,11 +192,9 @@ router.get('/api/configs/webdav', (_req, res) => { name: row.name, url: row.url, username: row.username, - password: maskPassword(decryptedPwd), + password: shouldDecrypt ? decryptedPwd : maskPassword(decryptedPwd), path: row.path, - is_default: !!row.is_default, - created_at: row.created_at, - updated_at: row.updated_at, + isActive: !!row.is_active, }; }); res.json(configs); @@ -160,31 +208,75 @@ router.get('/api/configs/webdav', (_req, res) => { router.post('/api/configs/webdav', (req, res) => { try { const db = getDb(); - const { name, url, username, password, path, is_default } = req.body as Record; + const { name, url, username, password, path, isActive } = req.body as Record; const encryptedPwd = password && typeof password === 'string' ? encrypt(password, config.encryptionKey) : null; - const now = new Date().toISOString(); const result = db.prepare( - 'INSERT INTO webdav_configs (name, url, username, password_encrypted, path, is_default, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO webdav_configs (name, url, username, password_encrypted, path, is_active) VALUES (?, ?, ?, ?, ?, ?)' ).run( name ?? '', url ?? '', username ?? '', encryptedPwd, - path ?? '/', is_default ? 1 : 0, now, now + path ?? '/', isActive ? 1 : 0 ); - res.status(201).json({ id: result.lastInsertRowid, name, url, username, password: maskPassword(password as string), path, is_default: !!is_default }); + res.status(201).json({ id: result.lastInsertRowid, name, url, username, password: maskPassword(password as string), path, isActive: !!isActive }); } catch (err) { console.error('POST /api/configs/webdav error:', err); res.status(500).json({ error: 'Failed to create WebDAV config', code: 'CREATE_WEBDAV_CONFIG_FAILED' }); } }); +// PUT /api/configs/webdav/bulk — replace all WebDAV configs (for sync) +// MUST be registered before :id route to avoid matching 'bulk' as an id +router.put('/api/configs/webdav/bulk', (req, res) => { + try { + const db = getDb(); + const configs = req.body.configs as Array<{ + id: string; + name: string; + url: string; + username: string; + password: string; + path: string; + isActive: boolean; + }>; + + if (!Array.isArray(configs)) { + res.status(400).json({ error: 'configs array required', code: 'INVALID_REQUEST' }); + return; + } + + const bulkSync = db.transaction(() => { + db.prepare('DELETE FROM webdav_configs').run(); + + const stmt = db.prepare(` + INSERT INTO webdav_configs (id, name, url, username, password_encrypted, path, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + for (const c of configs) { + const encryptedPwd = c.password ? encrypt(c.password, config.encryptionKey) : ''; + stmt.run( + c.id, c.name ?? '', c.url ?? '', c.username ?? '', + encryptedPwd, c.path ?? '/', c.isActive ? 1 : 0 + ); + } + }); + + bulkSync(); + res.json({ synced: configs.length }); + } catch (err) { + console.error('PUT /api/configs/webdav/bulk error:', err); + res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); + } +}); + // PUT /api/configs/webdav/:id router.put('/api/configs/webdav/:id', (req, res) => { try { const db = getDb(); - const id = parseInt(req.params.id); - const { name, url, username, password, path, is_default } = req.body as Record; + const id = req.params.id; + const { name, url, username, password, path, isActive } = req.body as Record; let encryptedPwd: string | null = null; if (password && typeof password === 'string' && !password.startsWith('***')) { @@ -194,17 +286,16 @@ router.put('/api/configs/webdav/:id', (req, res) => { encryptedPwd = (existing?.password_encrypted as string) ?? null; } - const now = new Date().toISOString(); db.prepare( - 'UPDATE webdav_configs SET name = ?, url = ?, username = ?, password_encrypted = ?, path = ?, is_default = ?, updated_at = ? WHERE id = ?' - ).run(name ?? '', url ?? '', username ?? '', encryptedPwd, path ?? '/', is_default ? 1 : 0, now, id); + 'UPDATE webdav_configs SET name = ?, url = ?, username = ?, password_encrypted = ?, path = ?, is_active = ? WHERE id = ?' + ).run(name ?? '', url ?? '', username ?? '', encryptedPwd, path ?? '/', isActive ? 1 : 0, id); let maskedPwd = ''; if (encryptedPwd) { try { maskedPwd = maskPassword(decrypt(encryptedPwd, config.encryptionKey)); } catch { maskedPwd = '****'; } } - res.json({ id, name, url, username, password: maskedPwd, path, is_default: !!is_default }); + res.json({ id, name, url, username, password: maskedPwd, path, isActive: !!isActive }); } catch (err) { console.error('PUT /api/configs/webdav error:', err); res.status(500).json({ error: 'Failed to update WebDAV config', code: 'UPDATE_WEBDAV_CONFIG_FAILED' }); @@ -215,7 +306,7 @@ router.put('/api/configs/webdav/:id', (req, res) => { router.delete('/api/configs/webdav/:id', (req, res) => { try { const db = getDb(); - const id = parseInt(req.params.id); + const id = req.params.id; const result = db.prepare('DELETE FROM webdav_configs WHERE id = ?').run(id); if (result.changes === 0) { res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index ca019c2..7edb987 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -152,14 +152,14 @@ router.post('/api/sync/import', (req, res) => { const cats = data.categories as Record[] | undefined; if (Array.isArray(cats) && cats.length > 0) { const catStmt = db.prepare(` - INSERT OR REPLACE INTO categories (id, name, description, keywords, color, icon, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO categories (id, name, icon, keywords, is_custom) + VALUES (?, ?, ?, ?, ?) `); for (const c of cats) { catStmt.run( - c.id, c.name ?? '', c.description ?? null, + c.id, c.name ?? '', c.icon ?? '📁', typeof c.keywords === 'string' ? c.keywords : JSON.stringify(c.keywords ?? []), - c.color ?? null, c.icon ?? null, c.sort_order ?? 0 + c.is_custom ? 1 : 0 ); } counts.categories = cats.length; @@ -169,14 +169,13 @@ router.post('/api/sync/import', (req, res) => { const filters = data.asset_filters as Record[] | undefined; if (Array.isArray(filters) && filters.length > 0) { const filterStmt = db.prepare(` - INSERT OR REPLACE INTO asset_filters (id, name, description, keywords, platform, sort_order) - VALUES (?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO asset_filters (id, name, keywords) + VALUES (?, ?, ?) `); for (const f of filters) { filterStmt.run( - f.id, f.name ?? '', f.description ?? null, - typeof f.keywords === 'string' ? f.keywords : JSON.stringify(f.keywords ?? []), - f.platform ?? null, f.sort_order ?? 0 + f.id, f.name ?? '', + typeof f.keywords === 'string' ? f.keywords : JSON.stringify(f.keywords ?? []) ); } counts.asset_filters = filters.length; @@ -190,12 +189,13 @@ router.post('/api/sync/import', (req, res) => { const existingKey = (existing?.api_key_encrypted as string) ?? null; // Skip masked keys, keep existing encrypted value db.prepare(` - INSERT OR REPLACE INTO ai_configs (id, name, provider, model, base_url, api_key_encrypted, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - c.id, c.name ?? '', c.provider ?? '', c.model ?? '', c.base_url ?? null, + c.id, c.name ?? '', c.api_type ?? c.apiType ?? 'openai', c.model ?? '', c.base_url ?? c.baseUrl ?? null, existingKey, - c.is_default ? 1 : 0, c.created_at ?? null, c.updated_at ?? null + (c.is_active ?? c.isActive) ? 1 : 0, c.custom_prompt ?? c.customPrompt ?? null, + (c.use_custom_prompt ?? c.useCustomPrompt) ? 1 : 0, c.concurrency ?? 1 ); } counts.ai_configs = aiConfigs.length; @@ -208,12 +208,12 @@ router.post('/api/sync/import', (req, res) => { const existing = db.prepare('SELECT password_encrypted FROM webdav_configs WHERE id = ?').get(c.id) as Record | undefined; const existingPwd = (existing?.password_encrypted as string) ?? null; db.prepare(` - INSERT OR REPLACE INTO webdav_configs (id, name, url, username, password_encrypted, path, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO webdav_configs (id, name, url, username, password_encrypted, path, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?) `).run( c.id, c.name ?? '', c.url ?? '', c.username ?? '', existingPwd, - c.path ?? '/', c.is_default ? 1 : 0, c.created_at ?? null, c.updated_at ?? null + c.path ?? '/', (c.is_active ?? c.isActive) ? 1 : 0 ); } counts.webdav_configs = webdavConfigs.length; diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 9067425..77baeaf 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -57,6 +57,8 @@ export const SettingsPanel: React.FC = () => { deleteCustomCategory, backendApiSecret, setBackendApiSecret, + setAIConfigs, + setWebDAVConfigs, } = useAppStore(); const [showAIForm, setShowAIForm] = useState(false); @@ -524,9 +526,11 @@ Focus on practicality and accurate categorization to help users quickly understa try { await backend.syncRepositories(repositories); await backend.syncReleases(releases); + await backend.syncAIConfigs(aiConfigs); + await backend.syncWebDAVConfigs(webdavConfigs); alert(t( - `已同步到后端:仓库 ${repositories.length},发布 ${releases.length}`, - `Synced to backend: repositories ${repositories.length}, releases ${releases.length}` + `已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`, + `Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}` )); } catch (error) { console.error('Sync to backend failed:', error); @@ -551,6 +555,8 @@ Focus on practicality and accurate categorization to help users quickly understa try { const repoData = await backend.fetchRepositories(); const releaseData = await backend.fetchReleases(); + const aiConfigData = await backend.fetchAIConfigs(); + const webdavConfigData = await backend.fetchWebDAVConfigs(); if (repoData.repositories.length > 0) { setRepositories(repoData.repositories); @@ -558,10 +564,16 @@ Focus on practicality and accurate categorization to help users quickly understa if (releaseData.releases.length > 0) { setReleases(releaseData.releases); } + if (aiConfigData.length > 0) { + setAIConfigs(aiConfigData); + } + if (webdavConfigData.length > 0) { + setWebDAVConfigs(webdavConfigData); + } alert(t( - `已从后端同步:仓库 ${repoData.repositories.length},发布 ${releaseData.releases.length}`, - `Synced from backend: repositories ${repoData.repositories.length}, releases ${releaseData.releases.length}` + `已从后端同步:仓库 ${repoData.repositories.length},发布 ${releaseData.releases.length},AI配置 ${aiConfigData.length},WebDAV配置 ${webdavConfigData.length}`, + `Synced from backend: repos ${repoData.repositories.length}, releases ${releaseData.releases.length}, AI configs ${aiConfigData.length}, WebDAV configs ${webdavConfigData.length}` )); } catch (error) { console.error('Sync from backend failed:', error); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 0301b53..8e6f1a6 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -1,6 +1,6 @@ import { translateBackendError } from '../utils/backendErrors'; -import { Repository, Release } from '../types'; +import { Repository, Release, AIConfig, WebDAVConfig } from '../types'; class BackendAdapter { private _backendUrl: string | null = null; @@ -232,6 +232,49 @@ class BackendAdapter { return res.json() as Promise<{ releases: Release[]; total: number }>; } + async syncAIConfigs(configs: AIConfig[]): Promise { + if (!this._backendUrl) return; + + const res = await fetch(`${this._backendUrl}/configs/ai/bulk`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify({ configs }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error'); + } + + async fetchAIConfigs(): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/configs/ai?decrypt=true`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch AI configs error'); + return res.json() as Promise; + } + + async syncWebDAVConfigs(configs: WebDAVConfig[]): Promise { + if (!this._backendUrl) return; + + const res = await fetch(`${this._backendUrl}/configs/webdav/bulk`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify({ configs }) + }); + if (!res.ok) await this.throwTranslatedError(res, 'Sync WebDAV configs error'); + } + + async fetchWebDAVConfigs(): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/configs/webdav?decrypt=true`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch WebDAV configs error'); + return res.json() as Promise; + } + + async exportData(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 26e5b01..097ac40 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -19,12 +19,14 @@ interface AppActions { updateAIConfig: (id: string, updates: Partial) => void; deleteAIConfig: (id: string) => void; setActiveAIConfig: (id: string | null) => void; + setAIConfigs: (configs: AIConfig[]) => void; // WebDAV actions addWebDAVConfig: (config: WebDAVConfig) => void; updateWebDAVConfig: (id: string, updates: Partial) => void; deleteWebDAVConfig: (id: string) => void; setActiveWebDAVConfig: (id: string | null) => void; + setWebDAVConfigs: (configs: WebDAVConfig[]) => void; setLastBackup: (timestamp: string) => void; // Search actions @@ -238,6 +240,7 @@ export const useAppStore = create()( activeAIConfig: state.activeAIConfig === id ? null : state.activeAIConfig })), setActiveAIConfig: (activeAIConfig) => set({ activeAIConfig }), + setAIConfigs: (aiConfigs) => set({ aiConfigs }), // WebDAV actions addWebDAVConfig: (config) => set((state) => ({ @@ -253,6 +256,7 @@ export const useAppStore = create()( activeWebDAVConfig: state.activeWebDAVConfig === id ? null : state.activeWebDAVConfig })), setActiveWebDAVConfig: (activeWebDAVConfig) => set({ activeWebDAVConfig }), + setWebDAVConfigs: (webdavConfigs) => set({ webdavConfigs }), setLastBackup: (lastBackup) => set({ lastBackup }), // Search actions From 32a44872f49b74262dd68b193845d44e1ec5d724 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 09:05:34 +0800 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B7=A8?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动时自动从后端拉取最新数据(后端优先策略) - 本地数据变更后2秒防抖自动推送到后端 - 每5秒轮询后端拉取数据,实现跨设备无感同步 - 轮询时比较数据指纹,后端数据未变则跳过更新,避免覆盖编辑中的内容 - 同步AI/WebDAV配置及其勾选状态(activeAIConfig/activeWebDAVConfig) - 后端新增settings读写接口,前端新增autoSync服务 --- src/App.tsx | 21 +++- src/services/autoSync.ts | 206 +++++++++++++++++++++++++++++++++ src/services/backendAdapter.ts | 22 ++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/services/autoSync.ts diff --git a/src/App.tsx b/src/App.tsx index 262da90..c7a987b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; +import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; function App() { const { @@ -34,9 +35,25 @@ function App() { } }, [theme]); - // Initialize backend adapter + // Initialize backend adapter and auto-sync useEffect(() => { - backend.init(); + let unsubscribe: (() => void) | null = null; + + const initBackend = async () => { + await backend.init(); + if (backend.isAvailable) { + await syncFromBackend(); + unsubscribe = startAutoSync(); + } + }; + + initBackend(); + + return () => { + if (unsubscribe) { + stopAutoSync(unsubscribe); + } + }; }, []); // Show login screen if not authenticated diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts new file mode 100644 index 0000000..3ab0765 --- /dev/null +++ b/src/services/autoSync.ts @@ -0,0 +1,206 @@ +import { backend } from './backendAdapter'; +import { useAppStore } from '../store/useAppStore'; + +// Prevent sync loops: when we pull data FROM backend and update store, +// the store subscription would trigger a push TO backend. This flag blocks that. +let _isSyncingFromBackend = false; + +// Debounce timer for push-to-backend +let _debounceTimer: ReturnType | null = null; + +// Polling timer for pull-from-backend +let _pollTimer: ReturnType | null = null; + +// Polling interval in milliseconds +const POLL_INTERVAL = 5000; + +// Last known backend data fingerprints — skip store update if unchanged +let _lastHash = { + repos: '', + releases: '', + ai: '', + webdav: '', + settings: '', +}; + +function quickHash(data: unknown): string { + return JSON.stringify(data); +} + +/** + * Pull all data from backend and update local store. + * Backend-first strategy: backend data overwrites local data. + * Silent: errors logged to console only. + */ +export async function syncFromBackend(): Promise { + if (!backend.isAvailable) return; + + try { + const [reposResult, releasesResult, aiResult, webdavResult, settingsResult] = await Promise.allSettled([ + backend.fetchRepositories(), + backend.fetchReleases(), + backend.fetchAIConfigs(), + backend.fetchWebDAVConfigs(), + backend.fetchSettings(), + ]); + + let hasChanges = false; + + // Compare each result against last known hash — only update if backend data actually changed + if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + const hash = quickHash(reposResult.value.repositories); + if (hash !== _lastHash.repos) { + _lastHash.repos = hash; + hasChanges = true; + } + } + + if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + const hash = quickHash(releasesResult.value.releases); + if (hash !== _lastHash.releases) { + _lastHash.releases = hash; + hasChanges = true; + } + } + + if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + const hash = quickHash(aiResult.value); + if (hash !== _lastHash.ai) { + _lastHash.ai = hash; + hasChanges = true; + } + } + + if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + const hash = quickHash(webdavResult.value); + if (hash !== _lastHash.webdav) { + _lastHash.webdav = hash; + hasChanges = true; + } + } + + if (settingsResult.status === 'fulfilled') { + const hash = quickHash(settingsResult.value); + if (hash !== _lastHash.settings) { + _lastHash.settings = hash; + hasChanges = true; + } + } + + // Only update store if backend data actually changed + if (!hasChanges) return; + + _isSyncingFromBackend = true; + const state = useAppStore.getState(); + + if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + state.setRepositories(reposResult.value.repositories); + } + if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + state.setReleases(releasesResult.value.releases); + } + if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + state.setAIConfigs(aiResult.value); + } + if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + state.setWebDAVConfigs(webdavResult.value); + } + // Sync active selections from settings + if (settingsResult.status === 'fulfilled') { + const settings = settingsResult.value; + if (typeof settings.activeAIConfig === 'string' || settings.activeAIConfig === null) { + state.setActiveAIConfig(settings.activeAIConfig as string | null); + } + if (typeof settings.activeWebDAVConfig === 'string' || settings.activeWebDAVConfig === null) { + state.setActiveWebDAVConfig(settings.activeWebDAVConfig as string | null); + } + } + + console.log('✅ Synced from backend (data changed)'); + } catch (err) { + console.error('Failed to sync from backend:', err); + } finally { + _isSyncingFromBackend = false; + } +} + +/** + * Push current local state to backend. + * Silent: errors logged to console only. + */ +export async function syncToBackend(): Promise { + if (!backend.isAvailable) return; + if (_isSyncingFromBackend) return; + + try { + const state = useAppStore.getState(); + + await Promise.allSettled([ + backend.syncRepositories(state.repositories), + backend.syncReleases(state.releases), + backend.syncAIConfigs(state.aiConfigs), + backend.syncWebDAVConfigs(state.webdavConfigs), + backend.syncSettings({ + activeAIConfig: state.activeAIConfig, + activeWebDAVConfig: state.activeWebDAVConfig, + }), + ]); + + console.log('✅ Synced to backend'); + } catch (err) { + console.error('Failed to sync to backend:', err); + } +} + +/** + * Subscribe to Zustand store changes and auto-push to backend with 2s debounce. + * Returns an unsubscribe function for cleanup. + */ +export function startAutoSync(): () => void { + // 1. Subscribe to local changes → push to backend (2s debounce) + const unsubscribe = useAppStore.subscribe((state, prevState) => { + if (_isSyncingFromBackend) return; + + const changed = + state.repositories !== prevState.repositories || + state.releases !== prevState.releases || + state.aiConfigs !== prevState.aiConfigs || + state.webdavConfigs !== prevState.webdavConfigs || + state.activeAIConfig !== prevState.activeAIConfig || + state.activeWebDAVConfig !== prevState.activeWebDAVConfig; + + if (!changed) return; + + // Debounce: wait 2s after last change before pushing + if (_debounceTimer) { + clearTimeout(_debounceTimer); + } + _debounceTimer = setTimeout(() => { + syncToBackend(); + }, 2000); + }); + + // 2. Poll backend every 5s → pull fresh data for cross-device sync + _pollTimer = setInterval(() => { + syncFromBackend(); + }, POLL_INTERVAL); + + console.log('🔄 Auto-sync started (push debounce: 2s, poll: 5s)'); + return unsubscribe; +} + +/** + * Stop auto-sync: clear debounce timer and unsubscribe from store. + */ +export function stopAutoSync(unsubscribe: () => void): void { + if (_debounceTimer) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } + unsubscribe(); + console.log('🔄 Auto-sync stopped'); +} diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 8e6f1a6..ceb1d96 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -275,6 +275,28 @@ class BackendAdapter { } + // === Settings (active selections) === + + async syncSettings(settings: Record): Promise { + if (!this._backendUrl) return; + + await fetch(`${this._backendUrl}/settings`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(settings) + }); + } + + async fetchSettings(): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/settings`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch settings error'); + return res.json() as Promise>; + } + async exportData(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); From 66ea2e31f65f342f6dfe22aee139fbdd902716b3 Mon Sep 17 00:00:00 2001 From: Micah-Zheng <102610064+Micah-Zheng@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:08:27 +0800 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=B7=A8?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 启动时自动从后端拉取最新数据(后端优先策略) - 本地数据变更后2秒防抖自动推送到后端 - 每5秒轮询后端拉取数据,实现跨设备无感同步 - 轮询时比较数据指纹,后端数据未变则跳过更新,避免覆盖编辑中的内容 - 同步AI/WebDAV配置及其勾选状态(activeAIConfig/activeWebDAVConfig) - 后端新增settings读写接口,前端新增autoSync服务 Co-authored-by: Micah Zheng --- src/App.tsx | 21 +++- src/services/autoSync.ts | 206 +++++++++++++++++++++++++++++++++ src/services/backendAdapter.ts | 22 ++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 src/services/autoSync.ts diff --git a/src/App.tsx b/src/App.tsx index 262da90..c7a987b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; +import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; function App() { const { @@ -34,9 +35,25 @@ function App() { } }, [theme]); - // Initialize backend adapter + // Initialize backend adapter and auto-sync useEffect(() => { - backend.init(); + let unsubscribe: (() => void) | null = null; + + const initBackend = async () => { + await backend.init(); + if (backend.isAvailable) { + await syncFromBackend(); + unsubscribe = startAutoSync(); + } + }; + + initBackend(); + + return () => { + if (unsubscribe) { + stopAutoSync(unsubscribe); + } + }; }, []); // Show login screen if not authenticated diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts new file mode 100644 index 0000000..3ab0765 --- /dev/null +++ b/src/services/autoSync.ts @@ -0,0 +1,206 @@ +import { backend } from './backendAdapter'; +import { useAppStore } from '../store/useAppStore'; + +// Prevent sync loops: when we pull data FROM backend and update store, +// the store subscription would trigger a push TO backend. This flag blocks that. +let _isSyncingFromBackend = false; + +// Debounce timer for push-to-backend +let _debounceTimer: ReturnType | null = null; + +// Polling timer for pull-from-backend +let _pollTimer: ReturnType | null = null; + +// Polling interval in milliseconds +const POLL_INTERVAL = 5000; + +// Last known backend data fingerprints — skip store update if unchanged +let _lastHash = { + repos: '', + releases: '', + ai: '', + webdav: '', + settings: '', +}; + +function quickHash(data: unknown): string { + return JSON.stringify(data); +} + +/** + * Pull all data from backend and update local store. + * Backend-first strategy: backend data overwrites local data. + * Silent: errors logged to console only. + */ +export async function syncFromBackend(): Promise { + if (!backend.isAvailable) return; + + try { + const [reposResult, releasesResult, aiResult, webdavResult, settingsResult] = await Promise.allSettled([ + backend.fetchRepositories(), + backend.fetchReleases(), + backend.fetchAIConfigs(), + backend.fetchWebDAVConfigs(), + backend.fetchSettings(), + ]); + + let hasChanges = false; + + // Compare each result against last known hash — only update if backend data actually changed + if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + const hash = quickHash(reposResult.value.repositories); + if (hash !== _lastHash.repos) { + _lastHash.repos = hash; + hasChanges = true; + } + } + + if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + const hash = quickHash(releasesResult.value.releases); + if (hash !== _lastHash.releases) { + _lastHash.releases = hash; + hasChanges = true; + } + } + + if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + const hash = quickHash(aiResult.value); + if (hash !== _lastHash.ai) { + _lastHash.ai = hash; + hasChanges = true; + } + } + + if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + const hash = quickHash(webdavResult.value); + if (hash !== _lastHash.webdav) { + _lastHash.webdav = hash; + hasChanges = true; + } + } + + if (settingsResult.status === 'fulfilled') { + const hash = quickHash(settingsResult.value); + if (hash !== _lastHash.settings) { + _lastHash.settings = hash; + hasChanges = true; + } + } + + // Only update store if backend data actually changed + if (!hasChanges) return; + + _isSyncingFromBackend = true; + const state = useAppStore.getState(); + + if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + state.setRepositories(reposResult.value.repositories); + } + if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + state.setReleases(releasesResult.value.releases); + } + if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + state.setAIConfigs(aiResult.value); + } + if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + state.setWebDAVConfigs(webdavResult.value); + } + // Sync active selections from settings + if (settingsResult.status === 'fulfilled') { + const settings = settingsResult.value; + if (typeof settings.activeAIConfig === 'string' || settings.activeAIConfig === null) { + state.setActiveAIConfig(settings.activeAIConfig as string | null); + } + if (typeof settings.activeWebDAVConfig === 'string' || settings.activeWebDAVConfig === null) { + state.setActiveWebDAVConfig(settings.activeWebDAVConfig as string | null); + } + } + + console.log('✅ Synced from backend (data changed)'); + } catch (err) { + console.error('Failed to sync from backend:', err); + } finally { + _isSyncingFromBackend = false; + } +} + +/** + * Push current local state to backend. + * Silent: errors logged to console only. + */ +export async function syncToBackend(): Promise { + if (!backend.isAvailable) return; + if (_isSyncingFromBackend) return; + + try { + const state = useAppStore.getState(); + + await Promise.allSettled([ + backend.syncRepositories(state.repositories), + backend.syncReleases(state.releases), + backend.syncAIConfigs(state.aiConfigs), + backend.syncWebDAVConfigs(state.webdavConfigs), + backend.syncSettings({ + activeAIConfig: state.activeAIConfig, + activeWebDAVConfig: state.activeWebDAVConfig, + }), + ]); + + console.log('✅ Synced to backend'); + } catch (err) { + console.error('Failed to sync to backend:', err); + } +} + +/** + * Subscribe to Zustand store changes and auto-push to backend with 2s debounce. + * Returns an unsubscribe function for cleanup. + */ +export function startAutoSync(): () => void { + // 1. Subscribe to local changes → push to backend (2s debounce) + const unsubscribe = useAppStore.subscribe((state, prevState) => { + if (_isSyncingFromBackend) return; + + const changed = + state.repositories !== prevState.repositories || + state.releases !== prevState.releases || + state.aiConfigs !== prevState.aiConfigs || + state.webdavConfigs !== prevState.webdavConfigs || + state.activeAIConfig !== prevState.activeAIConfig || + state.activeWebDAVConfig !== prevState.activeWebDAVConfig; + + if (!changed) return; + + // Debounce: wait 2s after last change before pushing + if (_debounceTimer) { + clearTimeout(_debounceTimer); + } + _debounceTimer = setTimeout(() => { + syncToBackend(); + }, 2000); + }); + + // 2. Poll backend every 5s → pull fresh data for cross-device sync + _pollTimer = setInterval(() => { + syncFromBackend(); + }, POLL_INTERVAL); + + console.log('🔄 Auto-sync started (push debounce: 2s, poll: 5s)'); + return unsubscribe; +} + +/** + * Stop auto-sync: clear debounce timer and unsubscribe from store. + */ +export function stopAutoSync(unsubscribe: () => void): void { + if (_debounceTimer) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } + unsubscribe(); + console.log('🔄 Auto-sync stopped'); +} diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 8e6f1a6..ceb1d96 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -275,6 +275,28 @@ class BackendAdapter { } + // === Settings (active selections) === + + async syncSettings(settings: Record): Promise { + if (!this._backendUrl) return; + + await fetch(`${this._backendUrl}/settings`, { + method: 'PUT', + headers: this.getAuthHeaders(), + body: JSON.stringify(settings) + }); + } + + async fetchSettings(): Promise> { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await fetch(`${this._backendUrl}/settings`, { + headers: this.getAuthHeaders() + }); + if (!res.ok) await this.throwTranslatedError(res, 'Fetch settings error'); + return res.json() as Promise>; + } + async exportData(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); From f83856ebe28f209bace5295ad2649d227c39c59d Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 10:38:34 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20AI=E8=AF=B7=E6=B1=82=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=90=86=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=EF=BC=8C=E8=A7=A3=E5=86=B3Docker=E5=86=85=E9=83=A8URL=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E4=BB=8E=E6=B5=8F=E8=A7=88=E5=99=A8=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/aiService.ts | 146 +++++++++++++++++++++----------------- 1 file changed, 81 insertions(+), 65 deletions(-) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 97bafd4..2d0f897 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,4 +1,5 @@ import { Repository, AIConfig } from '../types'; +import { backend } from './backendAdapter'; export class AIService { private config: AIConfig; @@ -50,34 +51,40 @@ export class AIService { const apiType = this.getApiType(); if (apiType === 'openai') { - const url = this.buildApiUrl('v1/chat/completions'); const messages = [ ...(options.system.trim() ? [{ role: 'system', content: options.system }] : []), { role: 'user', content: options.user }, ]; - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${this.config.apiKey}`, - }, - body: JSON.stringify({ - model: this.config.model, - messages, - temperature: options.temperature, - max_tokens: options.maxTokens, - }), - signal: options.signal, - }); + const requestBody = { + model: this.config.model, + messages, + temperature: options.temperature, + max_tokens: options.maxTokens, + }; - if (!response.ok) { - throw new Error(`AI API error: ${response.status} ${response.statusText}`); + let data: any; + if (backend.isAvailable && this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody); + } else { + const url = this.buildApiUrl('v1/chat/completions'); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: options.signal, + }); + if (!response.ok) { + throw new Error(`AI API error: ${response.status} ${response.statusText}`); + } + data = await response.json(); } - const data = await response.json(); const content = data.choices?.[0]?.message?.content; if (!content) { throw new Error('No content received from AI service'); @@ -86,31 +93,36 @@ export class AIService { } if (apiType === 'claude') { - const url = this.buildApiUrl('v1/messages'); - const body = { + const requestBody = { model: this.config.model, ...(options.system.trim() ? { system: options.system } : {}), messages: [{ role: 'user', content: options.user }], temperature: options.temperature, max_tokens: options.maxTokens, }; - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'x-api-key': this.config.apiKey, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - signal: options.signal, - }); - if (!response.ok) { - throw new Error(`AI API error: ${response.status} ${response.statusText}`); + let data: unknown; + if (backend.isAvailable && this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody); + } else { + const url = this.buildApiUrl('v1/messages'); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'x-api-key': this.config.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(requestBody), + signal: options.signal, + }); + if (!response.ok) { + throw new Error(`AI API error: ${response.status} ${response.statusText}`); + } + data = await response.json(); } - const data: unknown = await response.json(); const contentBlocks = (data as { content?: unknown }).content; if (Array.isArray(contentBlocks)) { const text = contentBlocks @@ -120,47 +132,52 @@ export class AIService { return block.type === 'text' && typeof block.text === 'string' ? block.text : ''; }) .join(''); - if (text) return text; } - throw new Error('No content received from AI service'); } // gemini const rawModel = this.config.model.trim(); const model = rawModel.startsWith('models/') ? rawModel.slice('models/'.length) : rawModel; - const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; - const urlObj = new URL(this.buildApiUrl(path)); - urlObj.searchParams.set('key', this.config.apiKey); - - const prompt = options.system ? `${options.system}\n\n${options.user}` : options.user; - const response = await fetch(urlObj.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify({ - contents: [ - { - role: 'user', - parts: [{ text: prompt }], - }, - ], - generationConfig: { - temperature: options.temperature, - maxOutputTokens: options.maxTokens, + const prompt = options.system ? `${options.system} + +${options.user}` : options.user; + const requestBody = { + contents: [ + { + role: 'user', + parts: [{ text: prompt }], }, - }), - signal: options.signal, - }); + ], + generationConfig: { + temperature: options.temperature, + maxOutputTokens: options.maxTokens, + }, + }; - if (!response.ok) { - throw new Error(`AI API error: ${response.status} ${response.statusText}`); + let data: unknown; + if (backend.isAvailable && this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody); + } else { + const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; + const urlObj = new URL(this.buildApiUrl(path)); + urlObj.searchParams.set('key', this.config.apiKey); + const response = await fetch(urlObj.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: options.signal, + }); + if (!response.ok) { + throw new Error(`AI API error: ${response.status} ${response.statusText}`); + } + data = await response.json(); } - const data: unknown = await response.json(); const candidates = (data as { candidates?: unknown }).candidates; if (Array.isArray(candidates) && candidates.length > 0) { const parts = (candidates[0] as { content?: { parts?: unknown } }).content?.parts; @@ -175,7 +192,6 @@ export class AIService { if (text) return text; } } - throw new Error('No content received from AI service'); } From cffcaa65f44dd05326d95dc193cc5dfd8ff3d54f Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 10:54:08 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=845=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.ts: releases 表添加缺失的 prerelease 和 draft 列 - auth.ts: 健康检查路径从 /api/health 改为 /health(中间件挂载点相对路径) - sync.ts: AI 配置 upsert 参数顺序修正(base_url 和 model 位置对调) - proxyService.ts: 日志中的 URL 脱敏,防止泄露 Gemini API key 等敏感参数 - autoSync.ts: 移除 length > 0 检查,允许空数组同步以支持删除传播 --- server/src/db/schema.ts | 2 ++ server/src/middleware/auth.ts | 2 +- server/src/routes/sync.ts | 4 ++-- server/src/services/proxyService.ts | 16 ++++++++++++++-- src/services/autoSync.ts | 16 ++++++++-------- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index ef5e2e7..2f31e4e 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -45,6 +45,8 @@ export function initializeSchema(db: Database.Database): void { repo_id INTEGER NOT NULL, repo_full_name TEXT NOT NULL, repo_name TEXT NOT NULL, + prerelease INTEGER DEFAULT 0, + draft INTEGER DEFAULT 0, is_read INTEGER DEFAULT 0 ); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 232b47d..3487215 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -6,7 +6,7 @@ let warnedOnce = false; export function authMiddleware(req: Request, res: Response, next: NextFunction): void { // Skip auth for health check - if (req.method === 'GET' && req.path === '/api/health') { + if (req.method === 'GET' && req.path === '/health') { next(); return; } diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 7edb987..4bb4888 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -192,8 +192,8 @@ router.post('/api/sync/import', (req, res) => { INSERT OR REPLACE INTO ai_configs (id, name, api_type, base_url, api_key_encrypted, model, is_active, custom_prompt, use_custom_prompt, concurrency) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - c.id, c.name ?? '', c.api_type ?? c.apiType ?? 'openai', c.model ?? '', c.base_url ?? c.baseUrl ?? null, - existingKey, + c.id, c.name ?? '', c.api_type ?? c.apiType ?? 'openai', c.base_url ?? c.baseUrl ?? null, + existingKey, c.model ?? '', (c.is_active ?? c.isActive) ? 1 : 0, c.custom_prompt ?? c.customPrompt ?? null, (c.use_custom_prompt ?? c.useCustomPrompt) ? 1 : 0, c.concurrency ?? 1 ); diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 9945858..3d65639 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -12,13 +12,25 @@ export interface ProxyResponse { data: unknown; } +function redactUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + for (const key of ['key', 'api_key', 'token', 'access_token']) { + if (url.searchParams.has(key)) url.searchParams.set(key, '***'); + } + return url.toString(); + } catch { + return rawUrl; + } +} + export async function proxyRequest(options: ProxyRequestOptions): Promise { const { url, method, headers = {}, body, timeout = 30000 } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { - console.log(`[Proxy] ${method} ${url}`); + console.log(`[Proxy] ${method} ${redactUrl(url)}`); const fetchOptions: RequestInit = { method, @@ -36,7 +48,7 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise ${response.status}`); + console.log(`[Proxy] ${method} ${redactUrl(url)} -> ${response.status}`); const responseHeaders: Record = {}; response.headers.forEach((value, key) => { diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 3ab0765..eac1a18 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -47,7 +47,7 @@ export async function syncFromBackend(): Promise { let hasChanges = false; // Compare each result against last known hash — only update if backend data actually changed - if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + if (reposResult.status === 'fulfilled') { const hash = quickHash(reposResult.value.repositories); if (hash !== _lastHash.repos) { _lastHash.repos = hash; @@ -55,7 +55,7 @@ export async function syncFromBackend(): Promise { } } - if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + if (releasesResult.status === 'fulfilled') { const hash = quickHash(releasesResult.value.releases); if (hash !== _lastHash.releases) { _lastHash.releases = hash; @@ -63,7 +63,7 @@ export async function syncFromBackend(): Promise { } } - if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + if (aiResult.status === 'fulfilled') { const hash = quickHash(aiResult.value); if (hash !== _lastHash.ai) { _lastHash.ai = hash; @@ -71,7 +71,7 @@ export async function syncFromBackend(): Promise { } } - if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + if (webdavResult.status === 'fulfilled') { const hash = quickHash(webdavResult.value); if (hash !== _lastHash.webdav) { _lastHash.webdav = hash; @@ -93,16 +93,16 @@ export async function syncFromBackend(): Promise { _isSyncingFromBackend = true; const state = useAppStore.getState(); - if (reposResult.status === 'fulfilled' && reposResult.value.repositories.length > 0) { + if (reposResult.status === 'fulfilled') { state.setRepositories(reposResult.value.repositories); } - if (releasesResult.status === 'fulfilled' && releasesResult.value.releases.length > 0) { + if (releasesResult.status === 'fulfilled') { state.setReleases(releasesResult.value.releases); } - if (aiResult.status === 'fulfilled' && aiResult.value.length > 0) { + if (aiResult.status === 'fulfilled') { state.setAIConfigs(aiResult.value); } - if (webdavResult.status === 'fulfilled' && webdavResult.value.length > 0) { + if (webdavResult.status === 'fulfilled') { state.setWebDAVConfigs(webdavResult.value); } // Sync active selections from settings From bae5c3480995724c2e57ab621327396e64eedf13 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 10:58:50 +0800 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20proxyService?= =?UTF-8?q?=203=E4=B8=AA=20Minor=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 扩展 redactUrl 敏感参数列表(添加 secret/apikey/client_secret/password/auth) - Content-Type 检查改为大小写不敏感,防止注入重复 header - 空响应体安全处理,先读 text 再 JSON.parse,避免 204 响应误报 502 --- server/src/services/proxyService.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 3d65639..36ebce5 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -15,7 +15,7 @@ export interface ProxyResponse { function redactUrl(rawUrl: string): string { try { const url = new URL(rawUrl); - for (const key of ['key', 'api_key', 'token', 'access_token']) { + for (const key of ['key', 'api_key', 'apikey', 'token', 'access_token', 'secret', 'client_secret', 'password', 'auth']) { if (url.searchParams.has(key)) url.searchParams.set(key, '***'); } return url.toString(); @@ -40,7 +40,10 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise k.toLowerCase() === 'content-type' + ); + if (!hasContentType) { (fetchOptions.headers as Record)['Content-Type'] = 'application/json'; } } @@ -57,10 +60,15 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise 0) { + try { + data = JSON.parse(text); + } catch { + data = text; + } } else { - data = await response.text(); + data = text; } return { status: response.status, headers: responseHeaders, data }; From 8faefce797e90e444ed7042a751b0dc8ce586229 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 11:24:29 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E6=8F=90=E5=87=BA=E7=9A=84=E6=89=80=E6=9C=89=20Critical/Major/?= =?UTF-8?q?Minor=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: 删除重复 services 块(Critical) - server/Dockerfile: 升级 Node 22 + 移除 devDependencies(Major x2) - errorHandler: 添加 headersSent 检查 + 传播 error code(Major) - crypto: 校验加密密钥长度(Major) - index.ts: 修复 Windows 主模块检测(Minor) - 删除 server/postcss.config.js(Minor) - proxy.ts: 防止 WebDAV Authorization 被覆盖(Major) - releases.ts: 校验 release id + 分页 clamp(Major x2) - repositories.ts: 分页 clamp(Major) - configs.ts: 更新不存在记录返回 404 + bulk 同步保留已有密钥(Major x2) - sync.ts: 补全 category/asset-filter 缺失字段(Major) - proxyService.ts: SSRF 防护 + timeout 覆盖 body 读取(Major x2) - backendAdapter.ts: AbortController 移入循环 + sync 方法错误处理(Major x2) - App.tsx: useEffect race condition 修复(Major) - useAppStore.ts: 不持久化 backendApiSecret(Major) - backendErrors.ts: getCurrentLanguage 类型安全(Minor) - autoSync.ts: syncToBackend 报告失败详情(Major) --- dist/index.html | 2 +- docker-compose.yml | 12 +------- server/Dockerfile | 6 ++-- server/postcss.config.js | 3 -- server/src/index.ts | 2 +- server/src/middleware/errorHandler.ts | 12 ++++++-- server/src/routes/configs.ts | 40 ++++++++++++++++++++++++--- server/src/routes/proxy.ts | 3 +- server/src/routes/releases.ts | 11 ++++++-- server/src/routes/repositories.ts | 4 +-- server/src/routes/sync.ts | 17 ++++++------ server/src/services/crypto.ts | 6 ++++ server/src/services/proxyService.ts | 22 +++++++++++++-- src/App.tsx | 16 ++++++++--- src/services/autoSync.ts | 9 ++++-- src/services/backendAdapter.ts | 18 ++++++------ src/store/useAppStore.ts | 3 +- src/utils/backendErrors.ts | 3 +- 18 files changed, 131 insertions(+), 58 deletions(-) delete mode 100644 server/postcss.config.js diff --git a/dist/index.html b/dist/index.html index 8208cf9..61c41c1 100644 --- a/dist/index.html +++ b/dist/index.html @@ -10,7 +10,7 @@ - + diff --git a/docker-compose.yml b/docker-compose.yml index 4d130b2..3b88516 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,14 +21,4 @@ services: restart: unless-stopped volumes: - backend-data: - -services: - github-stars-manager: - build: . - ports: - - "8080:80" - restart: unless-stopped - # Environment variables can be set here if needed - # environment: - # - NODE_ENV=production \ No newline at end of file + backend-data: \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile index 07a5241..7d37bad 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,11 +1,11 @@ -FROM node:18-alpine AS build +FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . -RUN npm run build +RUN npm run build && npm prune --omit=dev -FROM node:18-alpine +FROM node:22-alpine WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules diff --git a/server/postcss.config.js b/server/postcss.config.js deleted file mode 100644 index e74673a..0000000 --- a/server/postcss.config.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - plugins: [] -}; diff --git a/server/src/index.ts b/server/src/index.ts index 356cb6d..42ba420 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -76,7 +76,7 @@ function startServer(): void { } // Only start server when run directly (not imported for tests) -const isMainModule = process.argv[1] && new URL(import.meta.url).pathname === (process.platform === 'win32' ? process.argv[1].replace(/\\/g, '/') : process.argv[1]); +const isMainModule = process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isMainModule) { startServer(); } diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts index 749827c..4386201 100644 --- a/server/src/middleware/errorHandler.ts +++ b/server/src/middleware/errorHandler.ts @@ -8,11 +8,17 @@ export function errorHandler( ): void { console.error('Unhandled error:', err.stack || err.message); - const statusCode = (err as Error & { statusCode?: number }).statusCode || 500; + if (res.headersSent) { + return _next(err); + } + + const errWithMeta = err as Error & { statusCode?: number; code?: string }; + const statusCode = errWithMeta.statusCode || 500; const message = process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message || 'Internal Server Error'; + const code = errWithMeta.code || 'INTERNAL_SERVER_ERROR'; - res.status(statusCode).json({ error: message, code: 'INTERNAL_SERVER_ERROR' }); -} + res.status(statusCode).json({ error: message, code }); +} \ No newline at end of file diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index e77a7aa..f3b79fa 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -92,6 +92,13 @@ router.put('/api/configs/ai/bulk', (req, res) => { } const bulkSync = db.transaction(() => { + // Read existing keys BEFORE delete + const existingKeys = new Map(); + const existingRows = db.prepare('SELECT id, api_key_encrypted FROM ai_configs').all() as Array<{ id: string; api_key_encrypted: string }>; + for (const row of existingRows) { + if (row.api_key_encrypted) existingKeys.set(String(row.id), row.api_key_encrypted); + } + db.prepare('DELETE FROM ai_configs').run(); const stmt = db.prepare(` @@ -100,7 +107,12 @@ router.put('/api/configs/ai/bulk', (req, res) => { `); for (const c of configs) { - const encryptedKey = c.apiKey ? encrypt(c.apiKey, config.encryptionKey) : ''; + let encryptedKey = ''; + if (c.apiKey && !c.apiKey.startsWith('***')) { + encryptedKey = encrypt(c.apiKey, config.encryptionKey); + } else { + encryptedKey = existingKeys.get(String(c.id)) ?? ''; + } stmt.run( c.id, c.name ?? '', c.apiType ?? 'openai', c.baseUrl ?? '', encryptedKey, c.model ?? '', c.isActive ? 1 : 0, @@ -133,10 +145,14 @@ router.put('/api/configs/ai/:id', (req, res) => { encryptedKey = (existing?.api_key_encrypted as string) ?? null; } - db.prepare( + const result = db.prepare( 'UPDATE ai_configs SET name = ?, api_type = ?, model = ?, base_url = ?, api_key_encrypted = ?, is_active = ?, custom_prompt = ?, use_custom_prompt = ?, concurrency = ? WHERE id = ?' ).run(name ?? '', apiType ?? 'openai', model ?? '', baseUrl ?? null, encryptedKey, isActive ? 1 : 0, customPrompt ?? null, useCustomPrompt ? 1 : 0, concurrency ?? 1, id); + if (result.changes === 0) { + res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + return; + } let maskedKey = ''; if (encryptedKey) { try { maskedKey = maskApiKey(decrypt(encryptedKey, config.encryptionKey)); } catch { maskedKey = '****'; } @@ -247,6 +263,13 @@ router.put('/api/configs/webdav/bulk', (req, res) => { } const bulkSync = db.transaction(() => { + // Read existing passwords BEFORE delete + const existingPwds = new Map(); + const existingRows = db.prepare('SELECT id, password_encrypted FROM webdav_configs').all() as Array<{ id: string; password_encrypted: string }>; + for (const row of existingRows) { + if (row.password_encrypted) existingPwds.set(String(row.id), row.password_encrypted); + } + db.prepare('DELETE FROM webdav_configs').run(); const stmt = db.prepare(` @@ -255,7 +278,12 @@ router.put('/api/configs/webdav/bulk', (req, res) => { `); for (const c of configs) { - const encryptedPwd = c.password ? encrypt(c.password, config.encryptionKey) : ''; + let encryptedPwd = ''; + if (c.password && !c.password.startsWith('***')) { + encryptedPwd = encrypt(c.password, config.encryptionKey); + } else { + encryptedPwd = existingPwds.get(String(c.id)) ?? ''; + } stmt.run( c.id, c.name ?? '', c.url ?? '', c.username ?? '', encryptedPwd, c.path ?? '/', c.isActive ? 1 : 0 @@ -286,10 +314,14 @@ router.put('/api/configs/webdav/:id', (req, res) => { encryptedPwd = (existing?.password_encrypted as string) ?? null; } - db.prepare( + const result = db.prepare( 'UPDATE webdav_configs SET name = ?, url = ?, username = ?, password_encrypted = ?, path = ?, is_active = ? WHERE id = ?' ).run(name ?? '', url ?? '', username ?? '', encryptedPwd, path ?? '/', isActive ? 1 : 0, id); + if (result.changes === 0) { + res.status(404).json({ error: 'WebDAV config not found', code: 'WEBDAV_CONFIG_NOT_FOUND' }); + return; + } let maskedPwd = ''; if (encryptedPwd) { try { maskedPwd = maskPassword(decrypt(encryptedPwd, config.encryptionKey)); } catch { maskedPwd = '****'; } diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 4e91fb3..b3d864c 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -163,9 +163,10 @@ router.post('/api/proxy/webdav', async (req, res) => { const targetUrl = `${baseUrl}${path}`; const credentials = Buffer.from(`${username}:${password}`).toString('base64'); + const { Authorization: _ignored, ...safeHeaders } = extraHeaders || {}; const headers: Record = { + ...safeHeaders, 'Authorization': `Basic ${credentials}`, - ...(extraHeaders || {}), }; if (method === 'PROPFIND') { diff --git a/server/src/routes/releases.ts b/server/src/routes/releases.ts index 6bf3495..2327399 100644 --- a/server/src/routes/releases.ts +++ b/server/src/routes/releases.ts @@ -32,8 +32,8 @@ function transformRelease(row: Record) { router.get('/api/releases', (req, res) => { try { const db = getDb(); - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 50; + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(10000, Math.max(1, parseInt(req.query.limit as string) || 50)); const repoId = req.query.repo_id as string | undefined; const unread = req.query.unread as string | undefined; const offset = (page - 1) * limit; @@ -85,6 +85,13 @@ router.put('/api/releases', (req, res) => { return; } + for (const release of releases) { + if (!release.id) { + res.status(400).json({ error: 'Each release must have an id', code: 'RELEASE_ID_REQUIRED' }); + return; + } + } + const stmt = db.prepare(` INSERT OR REPLACE INTO releases ( id, tag_name, name, body, html_url, published_at, diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index ece1d95..cde7d38 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -42,8 +42,8 @@ function transformRepo(row: Record) { router.get('/api/repositories', (req, res) => { try { const db = getDb(); - const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 100; + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(10000, Math.max(1, parseInt(req.query.limit as string) || 100)); const search = req.query.search as string | undefined; const offset = (page - 1) * limit; diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts index 4bb4888..1784af8 100644 --- a/server/src/routes/sync.ts +++ b/server/src/routes/sync.ts @@ -152,14 +152,14 @@ router.post('/api/sync/import', (req, res) => { const cats = data.categories as Record[] | undefined; if (Array.isArray(cats) && cats.length > 0) { const catStmt = db.prepare(` - INSERT OR REPLACE INTO categories (id, name, icon, keywords, is_custom) - VALUES (?, ?, ?, ?, ?) + INSERT OR REPLACE INTO categories (id, name, description, icon, keywords, color, sort_order, is_custom) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); for (const c of cats) { catStmt.run( - c.id, c.name ?? '', c.icon ?? '📁', + c.id, c.name ?? '', c.description ?? null, c.icon ?? '📁', typeof c.keywords === 'string' ? c.keywords : JSON.stringify(c.keywords ?? []), - c.is_custom ? 1 : 0 + c.color ?? null, c.sort_order ?? 0, c.is_custom ? 1 : 0 ); } counts.categories = cats.length; @@ -169,13 +169,14 @@ router.post('/api/sync/import', (req, res) => { const filters = data.asset_filters as Record[] | undefined; if (Array.isArray(filters) && filters.length > 0) { const filterStmt = db.prepare(` - INSERT OR REPLACE INTO asset_filters (id, name, keywords) - VALUES (?, ?, ?) + INSERT OR REPLACE INTO asset_filters (id, name, description, keywords, platform, sort_order) + VALUES (?, ?, ?, ?, ?, ?) `); for (const f of filters) { filterStmt.run( - f.id, f.name ?? '', - typeof f.keywords === 'string' ? f.keywords : JSON.stringify(f.keywords ?? []) + f.id, f.name ?? '', f.description ?? null, + typeof f.keywords === 'string' ? f.keywords : JSON.stringify(f.keywords ?? []), + f.platform ?? null, f.sort_order ?? 0 ); } counts.asset_filters = filters.length; diff --git a/server/src/services/crypto.ts b/server/src/services/crypto.ts index aa3f295..630d119 100644 --- a/server/src/services/crypto.ts +++ b/server/src/services/crypto.ts @@ -5,6 +5,9 @@ const IV_LENGTH = 12; export function encrypt(plaintext: string, key: string): string { const keyBuffer = Buffer.from(key, 'hex'); + if (keyBuffer.length !== 32) { + throw new Error(`Invalid encryption key: expected 32 bytes (64 hex chars), got ${keyBuffer.length} bytes`); + } const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv); @@ -30,6 +33,9 @@ export function decrypt(encryptedStr: string, key: string): string { const [ivB64, ciphertextB64, authTagB64] = parts; const keyBuffer = Buffer.from(key, 'hex'); + if (keyBuffer.length !== 32) { + throw new Error(`Invalid encryption key: expected 32 bytes (64 hex chars), got ${keyBuffer.length} bytes`); + } const iv = Buffer.from(ivB64, 'base64'); const ciphertext = Buffer.from(ciphertextB64, 'base64'); const authTag = Buffer.from(authTagB64, 'base64'); diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 36ebce5..5bcfe25 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -24,12 +24,30 @@ function redactUrl(rawUrl: string): string { } } +const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '169.254.169.254']); +const PRIVATE_IP_PATTERNS = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./]; + +function validateUrl(rawUrl: string): void { + const parsed = new URL(rawUrl); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Blocked proxy request: unsupported protocol '${parsed.protocol}'`); + } + const hostname = parsed.hostname; + if (BLOCKED_HOSTS.has(hostname)) { + throw new Error(`Blocked proxy request: hostname '${hostname}' is not allowed`); + } + if (PRIVATE_IP_PATTERNS.some(p => p.test(hostname))) { + throw new Error(`Blocked proxy request: private IP '${hostname}' is not allowed`); + } +} + export async function proxyRequest(options: ProxyRequestOptions): Promise { const { url, method, headers = {}, body, timeout = 30000 } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { + validateUrl(url); console.log(`[Proxy] ${method} ${redactUrl(url)}`); const fetchOptions: RequestInit = { @@ -49,7 +67,6 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise ${response.status}`); @@ -73,11 +90,12 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise { let unsubscribe: (() => void) | null = null; + let cancelled = false; const initBackend = async () => { - await backend.init(); - if (backend.isAvailable) { - await syncFromBackend(); - unsubscribe = startAutoSync(); + try { + await backend.init(); + if (backend.isAvailable && !cancelled) { + await syncFromBackend(); + if (!cancelled) { + unsubscribe = startAutoSync(); + } + } + } catch (err) { + console.error('Failed to initialize backend:', err); } }; initBackend(); return () => { + cancelled = true; if (unsubscribe) { stopAutoSync(unsubscribe); } diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index eac1a18..33bc127 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -135,7 +135,7 @@ export async function syncToBackend(): Promise { try { const state = useAppStore.getState(); - await Promise.allSettled([ + const results = await Promise.allSettled([ backend.syncRepositories(state.repositories), backend.syncReleases(state.releases), backend.syncAIConfigs(state.aiConfigs), @@ -146,7 +146,12 @@ export async function syncToBackend(): Promise { }), ]); - console.log('✅ Synced to backend'); + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + console.warn(`⚠️ Synced to backend with ${failures.length} error(s):`, failures.map(f => (f as PromiseRejectedResult).reason)); + } else { + console.log('✅ Synced to backend'); + } } catch (err) { console.error('Failed to sync to backend:', err); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index ceb1d96..3997e69 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -7,9 +7,6 @@ class BackendAdapter { async init(): Promise { try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); - // Try common backend URLs const urls = [ window.location.origin + '/api', @@ -17,6 +14,8 @@ class BackendAdapter { ]; for (const baseUrl of urls) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); try { const res = await fetch(`${baseUrl}/health`, { signal: controller.signal, @@ -27,16 +26,16 @@ class BackendAdapter { if (data.status === 'ok') { this._backendUrl = baseUrl; console.log(`✅ Backend connected: ${baseUrl}`); - clearTimeout(timeoutId); return; } } } catch { // Try next URL + } finally { + clearTimeout(timeoutId); } } - clearTimeout(timeoutId); this._backendUrl = null; console.log('ℹ️ Backend not available, using local-only mode'); } catch { @@ -195,11 +194,12 @@ class BackendAdapter { async syncRepositories(repos: Repository[]): Promise { if (!this._backendUrl) return; - await fetch(`${this._backendUrl}/repositories`, { + const res = await fetch(`${this._backendUrl}/repositories`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ repositories: repos }) }); + if (!res.ok) await this.throwTranslatedError(res, 'Sync repositories error'); } async fetchRepositories(): Promise<{ repositories: Repository[]; total: number }> { @@ -215,11 +215,12 @@ class BackendAdapter { async syncReleases(releases: Release[]): Promise { if (!this._backendUrl) return; - await fetch(`${this._backendUrl}/releases`, { + const res = await fetch(`${this._backendUrl}/releases`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ releases }) }); + if (!res.ok) await this.throwTranslatedError(res, 'Sync releases error'); } async fetchReleases(): Promise<{ releases: Release[]; total: number }> { @@ -280,11 +281,12 @@ class BackendAdapter { async syncSettings(settings: Record): Promise { if (!this._backendUrl) return; - await fetch(`${this._backendUrl}/settings`, { + const res = await fetch(`${this._backendUrl}/settings`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify(settings) }); + if (!res.ok) await this.throwTranslatedError(res, 'Sync settings error'); } async fetchSettings(): Promise> { diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 097ac40..ded304a 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -367,8 +367,7 @@ export const useAppStore = create()( theme: state.theme, language: state.language, - // 持久化后端设置 - backendApiSecret: state.backendApiSecret, + // backendApiSecret: 保留在内存中,不持久化(安全考虑) // 持久化搜索排序设置 searchFilters: { diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts index 947f714..cefbb25 100644 --- a/src/utils/backendErrors.ts +++ b/src/utils/backendErrors.ts @@ -62,7 +62,8 @@ function getCurrentLanguage(): 'zh' | 'en' { const storeData = localStorage.getItem('github-stars-manager'); if (storeData) { const parsed = JSON.parse(storeData); - return parsed.state?.language || 'zh'; + const lang = parsed.state?.language; + if (lang === 'en') return 'en'; } } catch { /* ignore */ } return 'zh'; From f12da43de6264007ebd6461877850472c9facc20 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 11:37:28 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E8=BD=AE=E5=AE=A1=E6=9F=A5=E7=9A=84=208=20?= =?UTF-8?q?=E4=B8=AA=20Major=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. docker-compose.yml: 后端端口改用 expose 而非 ports,防止未认证暴露 2. server/Dockerfile: 添加 USER node 以非 root 用户运行容器 3. repositories.ts: parseJsonColumn 强制返回数组,防止非法类型泄漏 4. repositories.ts: 写入路径规范化列表字段为数组 5. repositories.ts: 布尔字段使用严格比较 (=== true || === 1) 6. autoSync.ts: syncFromBackend 添加重入锁防止并发竞态 7. autoSync.ts: startAutoSync 重复调用时先清理旧定时器 8. backendAdapter.ts: base64 解码改用 TextDecoder 支持多字节 UTF-8 --- docker-compose.yml | 4 ++-- server/Dockerfile | 3 ++- server/src/routes/repositories.ts | 27 +++++++++++++++------------ src/services/autoSync.ts | 15 ++++++++++++++- src/services/backendAdapter.ts | 4 +++- 5 files changed, 36 insertions(+), 17 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3b88516..85c99be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: backend: build: ./server - ports: - - "3000:3000" + expose: + - "3000" environment: - API_SECRET=${API_SECRET:-} - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} diff --git a/server/Dockerfile b/server/Dockerfile index 7d37bad..01d4b26 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,7 +10,8 @@ WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/package.json ./ -RUN mkdir -p data +RUN mkdir -p data && chown -R node:node /app +USER node VOLUME /app/data EXPOSE 3000 CMD ["node", "dist/index.js"] diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index cde7d38..89502c0 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -6,7 +6,10 @@ const router = Router(); // Helper to parse JSON columns safely function parseJsonColumn(value: unknown): unknown[] { if (typeof value !== 'string' || !value) return []; - try { return JSON.parse(value); } catch { return []; } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { return []; } } // Helper to transform DB row to API response @@ -106,15 +109,15 @@ router.put('/api/repositories', (req, res) => { repo.created_at ?? null, repo.updated_at ?? null, repo.pushed_at ?? null, repo.starred_at ?? null, owner?.login ?? '', owner?.avatar_url ?? null, - JSON.stringify(repo.topics ?? []), + JSON.stringify(Array.isArray(repo.topics) ? repo.topics : []), repo.ai_summary ?? null, - JSON.stringify(repo.ai_tags ?? []), - JSON.stringify(repo.ai_platforms ?? []), - repo.analyzed_at ?? null, repo.analysis_failed ? 1 : 0, + JSON.stringify(Array.isArray(repo.ai_tags) ? repo.ai_tags : []), + JSON.stringify(Array.isArray(repo.ai_platforms) ? repo.ai_platforms : []), + repo.analyzed_at ?? null, (repo.analysis_failed === true || repo.analysis_failed === 1) ? 1 : 0, repo.custom_description ?? null, - JSON.stringify(repo.custom_tags ?? []), + JSON.stringify(Array.isArray(repo.custom_tags) ? repo.custom_tags : []), repo.custom_category ?? null, repo.last_edited ?? null, - repo.subscribed_to_releases ? 1 : 0 + (repo.subscribed_to_releases === true || repo.subscribed_to_releases === 1) ? 1 : 0 ); count++; } @@ -138,15 +141,15 @@ router.patch('/api/repositories/:id', (req, res) => { const allowedFields: Record unknown> = { ai_summary: (v) => v, - ai_tags: (v) => JSON.stringify(v), - ai_platforms: (v) => JSON.stringify(v), + ai_tags: (v) => JSON.stringify(Array.isArray(v) ? v : []), + ai_platforms: (v) => JSON.stringify(Array.isArray(v) ? v : []), analyzed_at: (v) => v, - analysis_failed: (v) => v ? 1 : 0, + analysis_failed: (v) => (v === true || v === 1) ? 1 : 0, custom_description: (v) => v, - custom_tags: (v) => JSON.stringify(v), + custom_tags: (v) => JSON.stringify(Array.isArray(v) ? v : []), custom_category: (v) => v, last_edited: (v) => v, - subscribed_to_releases: (v) => v ? 1 : 0, + subscribed_to_releases: (v) => (v === true || v === 1) ? 1 : 0, description: (v) => v, name: (v) => v, }; diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 33bc127..f45c0c3 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -4,6 +4,7 @@ import { useAppStore } from '../store/useAppStore'; // Prevent sync loops: when we pull data FROM backend and update store, // the store subscription would trigger a push TO backend. This flag blocks that. let _isSyncingFromBackend = false; +let _isSyncingFromBackendActive = false; // Debounce timer for push-to-backend let _debounceTimer: ReturnType | null = null; @@ -33,7 +34,9 @@ function quickHash(data: unknown): string { * Silent: errors logged to console only. */ export async function syncFromBackend(): Promise { - if (!backend.isAvailable) return; + if (!backend.isAvailable || _isSyncingFromBackendActive) return; + + _isSyncingFromBackendActive = true; try { const [reposResult, releasesResult, aiResult, webdavResult, settingsResult] = await Promise.allSettled([ @@ -121,6 +124,7 @@ export async function syncFromBackend(): Promise { console.error('Failed to sync from backend:', err); } finally { _isSyncingFromBackend = false; + _isSyncingFromBackendActive = false; } } @@ -162,6 +166,15 @@ export async function syncToBackend(): Promise { * Returns an unsubscribe function for cleanup. */ export function startAutoSync(): () => void { + // Guard: if already running, stop previous instance first + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } + if (_debounceTimer) { + clearTimeout(_debounceTimer); + _debounceTimer = null; + } // 1. Subscribe to local changes → push to backend (2s debounce) const unsubscribe = useAppStore.subscribe((state, prevState) => { if (_isSyncingFromBackend) return; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 3997e69..cb21d6b 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -126,7 +126,9 @@ class BackendAdapter { if (!res.ok) return ''; const data = await res.json() as { encoding?: string; content?: string }; if (data.encoding === 'base64' && data.content) { - return atob(data.content); + const binaryStr = atob(data.content); + const bytes = Uint8Array.from(binaryStr, c => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); } return data.content || ''; } catch { From 64b3b9dfcdd33e11c5dfa5ac90e2060ee3c9e657 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 11:44:27 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E7=AC=AC=E4=BA=94=E8=BD=AE=E5=AE=A1=E6=9F=A5=E7=9A=84=202=20?= =?UTF-8?q?=E4=B8=AA=20Minor=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. repositories.ts: LIKE 搜索转义 % 和 _ 通配符防止意外匹配 2. backendAdapter.ts: 仅在开发环境探测 localhost:3000 --- server/src/routes/repositories.ts | 5 +++-- src/services/backendAdapter.ts | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts index 89502c0..0963044 100644 --- a/server/src/routes/repositories.ts +++ b/server/src/routes/repositories.ts @@ -54,8 +54,9 @@ router.get('/api/repositories', (req, res) => { const params: unknown[] = []; if (search) { - sql += ' WHERE name LIKE ? OR full_name LIKE ? OR description LIKE ? OR ai_summary LIKE ? OR ai_tags LIKE ?'; - const searchPattern = `%${search}%`; + const escaped = search.replace(/[%_\\]/g, '\\$&'); + sql += " WHERE name LIKE ? ESCAPE '\\' OR full_name LIKE ? ESCAPE '\\' OR description LIKE ? ESCAPE '\\' OR ai_summary LIKE ? ESCAPE '\\' OR ai_tags LIKE ? ESCAPE '\\'"; + const searchPattern = `%${escaped}%`; params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index cb21d6b..37d56bf 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -10,8 +10,11 @@ class BackendAdapter { // Try common backend URLs const urls = [ window.location.origin + '/api', - 'http://localhost:3000/api', ]; + // Only probe localhost in development + if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { + urls.push('http://localhost:3000/api'); + } for (const baseUrl of urls) { const controller = new AbortController(); From 23a1d4048971ed41c21479e047765b993b72964c Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 12:07:30 +0800 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E7=AC=AC=E5=85=AD=E8=BD=AE=E5=AE=A1=E6=9F=A5=E7=9A=84=201=20?= =?UTF-8?q?=E4=B8=AA=20Major=20=E5=92=8C=202=20=E4=B8=AA=20Minor=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backendAdapter.ts: 添加 fetchWithTimeout 辅助方法,所有 fetch 调用增加 30s 超时 - autoSync.ts: syncToBackend 后更新 _lastHash 防止下次轮询虚假覆盖 - backendErrors.ts: 补充 SYNC_AI_CONFIGS_FAILED/SYNC_WEBDAV_CONFIGS_FAILED/INVALID_REQUEST 错误码 --- src/services/autoSync.ts | 9 +++++++ src/services/backendAdapter.ts | 49 ++++++++++++++++++++-------------- src/utils/backendErrors.ts | 3 +++ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index f45c0c3..5a6ff40 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -156,6 +156,15 @@ export async function syncToBackend(): Promise { } else { console.log('✅ Synced to backend'); } + + // Update _lastHash so the next poll won't see a false "change" + _lastHash = { + repos: quickHash(state.repositories), + releases: quickHash(state.releases), + ai: quickHash(state.aiConfigs), + webdav: quickHash(state.webdavConfigs), + settings: quickHash({ activeAIConfig: state.activeAIConfig, activeWebDAVConfig: state.activeWebDAVConfig }), + }; } catch (err) { console.error('Failed to sync to backend:', err); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 37d56bf..60a8aed 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -74,6 +74,15 @@ class BackendAdapter { } return headers; } + private async fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 30000): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + } private async throwTranslatedError(res: Response, fallbackPrefix: string): Promise { let code: string | undefined; try { @@ -88,7 +97,7 @@ class BackendAdapter { async fetchStarredRepos(page = 1, perPage = 100): Promise { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/proxy/github/user/starred?page=${page}&per_page=${perPage}&sort=updated`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/github/user/starred?page=${page}&per_page=${perPage}&sort=updated`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ @@ -108,7 +117,7 @@ class BackendAdapter { async getCurrentUser(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/proxy/github/user`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/github/user`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ method: 'GET' }) @@ -121,7 +130,7 @@ class BackendAdapter { if (!this._backendUrl) throw new Error('Backend not available'); try { - const res = await fetch(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/readme`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/readme`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ method: 'GET' }) @@ -143,7 +152,7 @@ class BackendAdapter { if (!this._backendUrl) throw new Error('Backend not available'); try { - const res = await fetch(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/github/repos/${owner}/${repo}/releases?page=${page}&per_page=${perPage}`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ method: 'GET' }) @@ -158,7 +167,7 @@ class BackendAdapter { async checkRateLimit(): Promise<{ remaining: number; reset: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/proxy/github/rate_limit`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/github/rate_limit`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ method: 'GET' }) @@ -173,7 +182,7 @@ class BackendAdapter { async proxyAIRequest(configId: string, body: object): Promise { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/proxy/ai`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/ai`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ configId, body }) @@ -187,7 +196,7 @@ class BackendAdapter { async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record): Promise { if (!this._backendUrl) throw new Error('Backend not available'); - return fetch(`${this._backendUrl}/proxy/webdav`, { + return this.fetchWithTimeout(`${this._backendUrl}/proxy/webdav`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify({ configId, method, path, body, headers }) @@ -199,7 +208,7 @@ class BackendAdapter { async syncRepositories(repos: Repository[]): Promise { if (!this._backendUrl) return; - const res = await fetch(`${this._backendUrl}/repositories`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ repositories: repos }) @@ -210,7 +219,7 @@ class BackendAdapter { async fetchRepositories(): Promise<{ repositories: Repository[]; total: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/repositories?limit=10000`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/repositories?limit=10000`, { headers: this.getAuthHeaders() }); if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); @@ -220,7 +229,7 @@ class BackendAdapter { async syncReleases(releases: Release[]): Promise { if (!this._backendUrl) return; - const res = await fetch(`${this._backendUrl}/releases`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/releases`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ releases }) @@ -231,7 +240,7 @@ class BackendAdapter { async fetchReleases(): Promise<{ releases: Release[]; total: number }> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/releases?limit=10000`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/releases?limit=10000`, { headers: this.getAuthHeaders() }); if (!res.ok) await this.throwTranslatedError(res, 'Fetch error'); @@ -241,7 +250,7 @@ class BackendAdapter { async syncAIConfigs(configs: AIConfig[]): Promise { if (!this._backendUrl) return; - const res = await fetch(`${this._backendUrl}/configs/ai/bulk`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/ai/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ configs }) @@ -252,7 +261,7 @@ class BackendAdapter { async fetchAIConfigs(): Promise { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/configs/ai?decrypt=true`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/ai?decrypt=true`, { headers: this.getAuthHeaders() }); if (!res.ok) await this.throwTranslatedError(res, 'Fetch AI configs error'); @@ -262,7 +271,7 @@ class BackendAdapter { async syncWebDAVConfigs(configs: WebDAVConfig[]): Promise { if (!this._backendUrl) return; - const res = await fetch(`${this._backendUrl}/configs/webdav/bulk`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/webdav/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ configs }) @@ -273,7 +282,7 @@ class BackendAdapter { async fetchWebDAVConfigs(): Promise { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/configs/webdav?decrypt=true`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/webdav?decrypt=true`, { headers: this.getAuthHeaders() }); if (!res.ok) await this.throwTranslatedError(res, 'Fetch WebDAV configs error'); @@ -286,7 +295,7 @@ class BackendAdapter { async syncSettings(settings: Record): Promise { if (!this._backendUrl) return; - const res = await fetch(`${this._backendUrl}/settings`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/settings`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify(settings) @@ -297,7 +306,7 @@ class BackendAdapter { async fetchSettings(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/settings`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/settings`, { headers: this.getAuthHeaders() }); if (!res.ok) await this.throwTranslatedError(res, 'Fetch settings error'); @@ -307,7 +316,7 @@ class BackendAdapter { async exportData(): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/sync/export`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/sync/export`, { method: 'POST', headers: this.getAuthHeaders() }); @@ -318,7 +327,7 @@ class BackendAdapter { async importData(data: Record): Promise> { if (!this._backendUrl) throw new Error('Backend not available'); - const res = await fetch(`${this._backendUrl}/sync/import`, { + const res = await this.fetchWithTimeout(`${this._backendUrl}/sync/import`, { method: 'POST', headers: this.getAuthHeaders(), body: JSON.stringify(data) @@ -333,7 +342,7 @@ class BackendAdapter { if (!this._backendUrl) return null; try { - const res = await fetch(`${this._backendUrl}/health`); + const res = await this.fetchWithTimeout(`${this._backendUrl}/health`, undefined, 5000); if (res.ok) return res.json() as Promise<{ status: string; version: string; timestamp: string }>; return null; } catch { diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts index cefbb25..00dbe5b 100644 --- a/src/utils/backendErrors.ts +++ b/src/utils/backendErrors.ts @@ -48,6 +48,9 @@ const ERROR_MESSAGES: Record = { CREATE_WEBDAV_CONFIG_FAILED: { zh: '创建 WebDAV 配置失败', en: 'Failed to create WebDAV config' }, UPDATE_WEBDAV_CONFIG_FAILED: { zh: '更新 WebDAV 配置失败', en: 'Failed to update WebDAV config' }, DELETE_WEBDAV_CONFIG_FAILED: { zh: '删除 WebDAV 配置失败', en: 'Failed to delete WebDAV config' }, + SYNC_AI_CONFIGS_FAILED: { zh: '同步 AI 配置失败', en: 'Failed to sync AI configs' }, + SYNC_WEBDAV_CONFIGS_FAILED: { zh: '同步 WebDAV 配置失败', en: 'Failed to sync WebDAV configs' }, + INVALID_REQUEST: { zh: '无效的请求', en: 'Invalid request' }, FETCH_SETTINGS_FAILED: { zh: '获取设置失败', en: 'Failed to fetch settings' }, UPDATE_SETTINGS_FAILED: { zh: '更新设置失败', en: 'Failed to update settings' }, // Sync From 8b193e88498031389cfdb05999497ddcc75530fd Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 12:17:00 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20CodeRabbit=20?= =?UTF-8?q?=E7=AC=AC=E4=B8=83=E8=BD=AE=E5=AE=A1=E6=9F=A5=E7=9A=84=202=20?= =?UTF-8?q?=E4=B8=AA=20Major=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - autoSync.ts: startAutoSync() 重复调用时先清理之前的 store subscription 防止泄漏 - autoSync.ts: syncFromBackend/syncToBackend 按 slice 单独追踪变化,部分失败不影响其他 slice --- src/services/autoSync.ts | 52 ++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 5a6ff40..dc2dc7a 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -6,6 +6,9 @@ import { useAppStore } from '../store/useAppStore'; let _isSyncingFromBackend = false; let _isSyncingFromBackendActive = false; +// Track store subscription for cleanup on restart +let _storeUnsubscribe: (() => void) | null = null; + // Debounce timer for push-to-backend let _debounceTimer: ReturnType | null = null; @@ -47,14 +50,14 @@ export async function syncFromBackend(): Promise { backend.fetchSettings(), ]); - let hasChanges = false; + const changed = { repos: false, releases: false, ai: false, webdav: false, settings: false }; // Compare each result against last known hash — only update if backend data actually changed if (reposResult.status === 'fulfilled') { const hash = quickHash(reposResult.value.repositories); if (hash !== _lastHash.repos) { _lastHash.repos = hash; - hasChanges = true; + changed.repos = true; } } @@ -62,7 +65,7 @@ export async function syncFromBackend(): Promise { const hash = quickHash(releasesResult.value.releases); if (hash !== _lastHash.releases) { _lastHash.releases = hash; - hasChanges = true; + changed.releases = true; } } @@ -70,7 +73,7 @@ export async function syncFromBackend(): Promise { const hash = quickHash(aiResult.value); if (hash !== _lastHash.ai) { _lastHash.ai = hash; - hasChanges = true; + changed.ai = true; } } @@ -78,7 +81,7 @@ export async function syncFromBackend(): Promise { const hash = quickHash(webdavResult.value); if (hash !== _lastHash.webdav) { _lastHash.webdav = hash; - hasChanges = true; + changed.webdav = true; } } @@ -86,30 +89,30 @@ export async function syncFromBackend(): Promise { const hash = quickHash(settingsResult.value); if (hash !== _lastHash.settings) { _lastHash.settings = hash; - hasChanges = true; + changed.settings = true; } } // Only update store if backend data actually changed - if (!hasChanges) return; + if (!Object.values(changed).some(Boolean)) return; _isSyncingFromBackend = true; const state = useAppStore.getState(); - if (reposResult.status === 'fulfilled') { + if (changed.repos && reposResult.status === 'fulfilled') { state.setRepositories(reposResult.value.repositories); } - if (releasesResult.status === 'fulfilled') { + if (changed.releases && releasesResult.status === 'fulfilled') { state.setReleases(releasesResult.value.releases); } - if (aiResult.status === 'fulfilled') { + if (changed.ai && aiResult.status === 'fulfilled') { state.setAIConfigs(aiResult.value); } - if (webdavResult.status === 'fulfilled') { + if (changed.webdav && webdavResult.status === 'fulfilled') { state.setWebDAVConfigs(webdavResult.value); } // Sync active selections from settings - if (settingsResult.status === 'fulfilled') { + if (changed.settings && settingsResult.status === 'fulfilled') { const settings = settingsResult.value; if (typeof settings.activeAIConfig === 'string' || settings.activeAIConfig === null) { state.setActiveAIConfig(settings.activeAIConfig as string | null); @@ -149,6 +152,7 @@ export async function syncToBackend(): Promise { activeWebDAVConfig: state.activeWebDAVConfig, }), ]); + const [reposSync, releasesSync, aiSync, webdavSync, settingsSync] = results; const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { @@ -157,14 +161,17 @@ export async function syncToBackend(): Promise { console.log('✅ Synced to backend'); } - // Update _lastHash so the next poll won't see a false "change" - _lastHash = { - repos: quickHash(state.repositories), - releases: quickHash(state.releases), - ai: quickHash(state.aiConfigs), - webdav: quickHash(state.webdavConfigs), - settings: quickHash({ activeAIConfig: state.activeAIConfig, activeWebDAVConfig: state.activeWebDAVConfig }), - }; + // Only update _lastHash for successfully synced slices + if (reposSync.status === 'fulfilled') _lastHash.repos = quickHash(state.repositories); + if (releasesSync.status === 'fulfilled') _lastHash.releases = quickHash(state.releases); + if (aiSync.status === 'fulfilled') _lastHash.ai = quickHash(state.aiConfigs); + if (webdavSync.status === 'fulfilled') _lastHash.webdav = quickHash(state.webdavConfigs); + if (settingsSync.status === 'fulfilled') { + _lastHash.settings = quickHash({ + activeAIConfig: state.activeAIConfig, + activeWebDAVConfig: state.activeWebDAVConfig, + }); + } } catch (err) { console.error('Failed to sync to backend:', err); } @@ -176,6 +183,10 @@ export async function syncToBackend(): Promise { */ export function startAutoSync(): () => void { // Guard: if already running, stop previous instance first + if (_storeUnsubscribe) { + _storeUnsubscribe(); + _storeUnsubscribe = null; + } if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; @@ -206,6 +217,7 @@ export function startAutoSync(): () => void { syncToBackend(); }, 2000); }); + _storeUnsubscribe = unsubscribe; // 2. Poll backend every 5s → pull fresh data for cross-device sync _pollTimer = setInterval(() => { From 9595fa8db0872a17b1d35b310e4c2dec5307c7c9 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 12:45:32 +0800 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=20syncToBackend?= =?UTF-8?q?=20=E9=87=8D=E5=85=A5=E4=BF=9D=E6=8A=A4=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20stopAutoSync=20=E8=AE=A2=E9=98=85=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 _isPushingToBackend 防止并发推送重叠 - stopAutoSync 优先使用模块级 _storeUnsubscribe 清理 - 修复 syncToBackend 函数闭合括号缺失 --- src/services/autoSync.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index dc2dc7a..135481a 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -9,6 +9,9 @@ let _isSyncingFromBackendActive = false; // Track store subscription for cleanup on restart let _storeUnsubscribe: (() => void) | null = null; +// Prevent overlapping pushes to backend +let _isPushingToBackend = false; + // Debounce timer for push-to-backend let _debounceTimer: ReturnType | null = null; @@ -138,7 +141,9 @@ export async function syncFromBackend(): Promise { export async function syncToBackend(): Promise { if (!backend.isAvailable) return; if (_isSyncingFromBackend) return; + if (_isPushingToBackend) return; + _isPushingToBackend = true; try { const state = useAppStore.getState(); @@ -174,6 +179,8 @@ export async function syncToBackend(): Promise { } } catch (err) { console.error('Failed to sync to backend:', err); + } finally { + _isPushingToBackend = false; } } @@ -240,6 +247,11 @@ export function stopAutoSync(unsubscribe: () => void): void { clearInterval(_pollTimer); _pollTimer = null; } - unsubscribe(); + if (_storeUnsubscribe) { + _storeUnsubscribe(); + _storeUnsubscribe = null; + } else { + unsubscribe(); + } console.log('🔄 Auto-sync stopped'); } From 7a2bcdd957f9af7f1e98d90bab4ad3f25fa2a1e3 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 12:52:12 +0800 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=E5=BA=8F=E5=88=97=E5=8C=96=20pull?= =?UTF-8?q?/push=20=E9=98=B2=E6=AD=A2=20=5FlastHash=20=E7=AB=9E=E4=BA=89?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncFromBackend 期间阻止 syncToBackend 并发执行 - 新增 _hasPendingPush 队列机制,pull 完成后自动执行排队的 push - 修复 CodeRabbit 第九轮审查的 Major 问题 --- src/services/autoSync.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 135481a..f364f34 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -11,6 +11,8 @@ let _storeUnsubscribe: (() => void) | null = null; // Prevent overlapping pushes to backend let _isPushingToBackend = false; +// Queue a push if one is requested while a pull is in-flight +let _hasPendingPush = false; // Debounce timer for push-to-backend let _debounceTimer: ReturnType | null = null; @@ -40,7 +42,7 @@ function quickHash(data: unknown): string { * Silent: errors logged to console only. */ export async function syncFromBackend(): Promise { - if (!backend.isAvailable || _isSyncingFromBackendActive) return; + if (!backend.isAvailable || _isSyncingFromBackendActive || _isPushingToBackend) return; _isSyncingFromBackendActive = true; @@ -131,7 +133,11 @@ export async function syncFromBackend(): Promise { } finally { _isSyncingFromBackend = false; _isSyncingFromBackendActive = false; - } + // Drain pending push that was queued during pull + if (_hasPendingPush) { + _hasPendingPush = false; + void syncToBackend(); + } } /** @@ -140,10 +146,16 @@ export async function syncFromBackend(): Promise { */ export async function syncToBackend(): Promise { if (!backend.isAvailable) return; + // If a pull is in-flight, queue this push for after pull completes + if (_isSyncingFromBackendActive) { + _hasPendingPush = true; + return; + } if (_isSyncingFromBackend) return; if (_isPushingToBackend) return; _isPushingToBackend = true; + _hasPendingPush = false; try { const state = useAppStore.getState(); From c0f3dfcfeb622db83ff58c9fa16538ebbd718915 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 13:04:27 +0800 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20syncFromBacke?= =?UTF-8?q?nd=20finally=20=E5=9D=97=E9=97=AD=E5=90=88=E6=8B=AC=E5=8F=B7?= =?UTF-8?q?=E5=B9=B6=E9=87=8D=E7=BD=AE=20in-flight=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=A0=87=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Critical: syncFromBackend finally 块缺少闭合括号导致作用域错误 - 修复 Minor: startAutoSync/stopAutoSync 重置所有 in-flight 状态标志 - 防止重复调用永久阻塞同步 --- src/services/autoSync.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index f364f34..a492872 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -138,6 +138,7 @@ export async function syncFromBackend(): Promise { _hasPendingPush = false; void syncToBackend(); } + } } /** @@ -214,6 +215,10 @@ export function startAutoSync(): () => void { clearTimeout(_debounceTimer); _debounceTimer = null; } + // Reset in-flight state flags to prevent permanent sync blocking + _isPushingToBackend = false; + _isSyncingFromBackendActive = false; + _hasPendingPush = false; // 1. Subscribe to local changes → push to backend (2s debounce) const unsubscribe = useAppStore.subscribe((state, prevState) => { if (_isSyncingFromBackend) return; @@ -265,5 +270,10 @@ export function stopAutoSync(unsubscribe: () => void): void { } else { unsubscribe(); } + // Reset in-flight state flags + _isPushingToBackend = false; + _isSyncingFromBackendActive = false; + _isSyncingFromBackend = false; + _hasPendingPush = false; console.log('🔄 Auto-sync stopped'); } From 894c1895e6d84f740358b42d30c2cd8b85dc18b6 Mon Sep 17 00:00:00 2001 From: Micah Zheng Date: Wed, 25 Feb 2026 13:12:07 +0800 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20startAutoSync?= =?UTF-8?q?=20=E9=81=97=E6=BC=8F=20=5FisSyncingFromBackend=20=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E5=B9=B6=E5=BB=B6=E8=BF=9F=20=5FlastHash=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startAutoSync 现在重置所有四个 in-flight 状态标志 - syncFromBackend 中 _lastHash 改为在 store setter 成功后才更新 - 防止 setter 异常导致指纹与实际 store 状态不同步 --- src/services/autoSync.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index a492872..c498f50 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -57,11 +57,12 @@ export async function syncFromBackend(): Promise { const changed = { repos: false, releases: false, ai: false, webdav: false, settings: false }; - // Compare each result against last known hash — only update if backend data actually changed + // Compute hashes for each slice — only mark changed if hash differs + const hashes: Record = {}; if (reposResult.status === 'fulfilled') { const hash = quickHash(reposResult.value.repositories); if (hash !== _lastHash.repos) { - _lastHash.repos = hash; + hashes.repos = hash; changed.repos = true; } } @@ -69,7 +70,7 @@ export async function syncFromBackend(): Promise { if (releasesResult.status === 'fulfilled') { const hash = quickHash(releasesResult.value.releases); if (hash !== _lastHash.releases) { - _lastHash.releases = hash; + hashes.releases = hash; changed.releases = true; } } @@ -77,7 +78,7 @@ export async function syncFromBackend(): Promise { if (aiResult.status === 'fulfilled') { const hash = quickHash(aiResult.value); if (hash !== _lastHash.ai) { - _lastHash.ai = hash; + hashes.ai = hash; changed.ai = true; } } @@ -85,7 +86,7 @@ export async function syncFromBackend(): Promise { if (webdavResult.status === 'fulfilled') { const hash = quickHash(webdavResult.value); if (hash !== _lastHash.webdav) { - _lastHash.webdav = hash; + hashes.webdav = hash; changed.webdav = true; } } @@ -93,7 +94,7 @@ export async function syncFromBackend(): Promise { if (settingsResult.status === 'fulfilled') { const hash = quickHash(settingsResult.value); if (hash !== _lastHash.settings) { - _lastHash.settings = hash; + hashes.settings = hash; changed.settings = true; } } @@ -104,17 +105,22 @@ export async function syncFromBackend(): Promise { _isSyncingFromBackend = true; const state = useAppStore.getState(); + // Update store then commit hash — hash only changes if setter succeeds if (changed.repos && reposResult.status === 'fulfilled') { state.setRepositories(reposResult.value.repositories); + _lastHash.repos = hashes.repos; } if (changed.releases && releasesResult.status === 'fulfilled') { state.setReleases(releasesResult.value.releases); + _lastHash.releases = hashes.releases; } if (changed.ai && aiResult.status === 'fulfilled') { state.setAIConfigs(aiResult.value); + _lastHash.ai = hashes.ai; } if (changed.webdav && webdavResult.status === 'fulfilled') { state.setWebDAVConfigs(webdavResult.value); + _lastHash.webdav = hashes.webdav; } // Sync active selections from settings if (changed.settings && settingsResult.status === 'fulfilled') { @@ -125,6 +131,7 @@ export async function syncFromBackend(): Promise { if (typeof settings.activeWebDAVConfig === 'string' || settings.activeWebDAVConfig === null) { state.setActiveWebDAVConfig(settings.activeWebDAVConfig as string | null); } + _lastHash.settings = hashes.settings; } console.log('✅ Synced from backend (data changed)'); @@ -216,6 +223,7 @@ export function startAutoSync(): () => void { _debounceTimer = null; } // Reset in-flight state flags to prevent permanent sync blocking + _isSyncingFromBackend = false; _isPushingToBackend = false; _isSyncingFromBackendActive = false; _hasPendingPush = false;