commit da307c79cde5327c2008e711f61662a3862c45b1 Author: shuaiplus <2327005759@qq.com> Date: Tue Feb 3 22:56:42 2026 +0800 Basic success diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..97feb45 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,3 @@ +# JWT Secret for signing tokens (required) +# Generate one with: openssl rand -hex 32 +JWT_SECRET=your-secret-key-herexxs22fd2ds diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..aeede2f --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,36 @@ +# 自动同步上游更新 + +这个 GitHub Actions 工作流会自动将上游仓库(shuaiplus/nodewarden)的更新同步到你的 fork。 + +## 功能特性 + +- ✅ 每天自动检查并同步上游更新 +- ✅ 支持手动触发同步 +- ✅ 自动处理简单的合并 +- ⚠️ 遇到冲突时会提醒你手动处理 + +## 如何启用 + +1. 在你 fork 的仓库中,进入 **Actions** 标签页 +2. 点击 **I understand my workflows, go ahead and enable them** +3. 完成!工作流会自动运行 + +## 手动触发 + +1. 进入 **Actions** 标签页 +2. 选择 **Sync Fork with Upstream** +3. 点击 **Run workflow** → **Run workflow** + +## 注意事项 + +- 如果你修改了代码,可能会产生合并冲突 +- 遇到冲突时,工作流会失败,需要你手动解决 +- 建议不要修改核心代码,只修改配置文件 + +## 禁用自动同步 + +如果你不想自动同步: + +1. 进入 **Actions** 标签页 +2. 选择 **Sync Fork with Upstream** +3. 点击右上角的 **···** → **Disable workflow** diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000..2c11779 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,60 @@ +name: Sync Fork with Upstream + +on: + schedule: + # Run every day at 2:00 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + # Allow manual trigger + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sync with upstream + run: | + # Add upstream repository + git remote add upstream https://github.com/shuaiplus/nodewarden.git || true + + # Fetch upstream changes + git fetch upstream + + # Check if there are updates + BEHIND=$(git rev-list --count HEAD..upstream/main) + + if [ "$BEHIND" -gt 0 ]; then + echo "Found $BEHIND commits behind upstream. Syncing..." + + # Try to merge upstream/main into current branch + if git merge upstream/main --no-edit; then + echo "Merge successful!" + git push origin main + else + echo "Merge conflict detected. Please resolve manually." + echo "::warning::Failed to auto-sync due to merge conflicts. Manual intervention required." + exit 1 + fi + else + echo "Already up to date with upstream." + fi + + - name: Create summary + if: always() + run: | + echo "## Sync Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Upstream**: shuaiplus/nodewarden" >> $GITHUB_STEP_SUMMARY + echo "- **Branch**: main" >> $GITHUB_STEP_SUMMARY + echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7407ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ + +# Wrangler +.wrangler/ +.dev.vars + +# Build output +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local +.env.*.local + +# TypeScript +*.tsbuildinfo + +# Package lock (optional - remove if you want to commit it) +# package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e0615d --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# NodeWarden +一个基于 Cloudflare Workers 的 Bitwarden 兼容服务器实现,专为个人用户设计。 + +[English](./README_EN.md) | 中文 + +--- + +> **免责声明** +> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。 +> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。 + +--- + +## 特性 +- ✅ 完全免费,不需要在服务器上部署,再次感谢大善人! +- ✅ 完整的密码、笔记、卡片、身份信息管理 +- ✅ 文件夹和收藏功能 +- ✅ 文件附件支持(基于 R2 存储) +- ✅ 导入/导出功能 +- ✅ 网站图标获取 +- ✅ 登录限速保护(5 次失败后锁定 15 分钟) +- ✅ API 访问频率限制(60 次/分钟) +- ✅ 端到端加密(服务器无法查看明文) +- ✅ 兼容所有 Bitwarden 官方客户端 + +--- + +## 快速开始 + +### 一键部署 + +点击下方按钮部署到 Cloudflare Workers: + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) + +**部署步骤:** + +1. 使用 GitHub 登录并授权 +2. 登录 Cloudflare 账户 +3. **重要**:设置 `JWT_SECRET` 为强随机字符串(推荐使用 `openssl rand -hex 32` 生成) +4. KV 存储和 R2 存储桶将自动创建 +5. 点击 Deploy 等待部署完成 + +> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!** + +### 配置客户端 + +部署完成后,在任意 Bitwarden 客户端中: + +1. 打开设置(⚙️) +2. 选择「自托管环境」 +3. 服务器 URL 填入:`https://你的项目名` +4. 保存并返回登录页面 + +**首次注册**:直接访问 Workers 地址,在网页上完成账户注册。 + +--- + +## 手动部署 + +```bash +# 克隆项目 +git clone https://github.com/shuaiplus/nodewarden.git +cd nodewarden + +# 安装依赖 +npm install + +# 登录 Cloudflare +npx wrangler login + +# 创建 KV 存储 +npx wrangler kv namespace create VAULT +# 将输出的 id 填入 wrangler.toml 的 [[kv_namespaces]] + +# 创建 R2 存储桶(用于文件附件) +npx wrangler r2 bucket create nodewarden-attachments + +# 设置 JWT 密钥(请使用强随机字符串) +npx wrangler secret put JWT_SECRET +# 建议使用:openssl rand -hex 32 + +# 部署 +npm run deploy +``` + +--- + +## NodeWarden vs Vaultwarden + +NodeWarden 专注于**个人用户**的核心功能,保持代码简洁。以下是与 Vaultwarden 的功能对比: + +| 功能 | NodeWarden | Vaultwarden | 说明 | +|------|:----------:|:-----------:|------| +| 密码/笔记/卡片/身份 | ✅ | ✅ | 完整支持 | +| 文件夹 & 收藏 | ✅ | ✅ | 完整支持 | +| 文件附件 | ✅ | ✅ | 使用 R2 存储,100MB 限制 | +| 导入/导出 | ✅ | ✅ | 完整支持 | +| 网站图标 | ✅ | ✅ | 代理获取 | +| 登录限速 | ✅ | ✅ | 防暴力破解 | +| 单用户模式 | ✅ | ✅ | 个人使用 | +| Bitwarden Send | ❌ | ✅ | 安全分享功能 | +| 两步验证 (2FA) | ❌ | ✅ | TOTP/WebAuthn 等 | +| 紧急访问 | ❌ | ✅ | 紧急联系人访问 | +| 组织/团队 | ❌ | ✅ | 多用户协作 | +| 实时同步 (WebSocket) | ❌ | ✅ | 多设备即时推送 | +| 邮件通知 | ❌ | ✅ | 需要 SMTP | +| 修改主密码 | ❌ | ✅ | 重新加密数据 | +| Admin 管理页 | ❌ | ✅ | 后台管理 | + +> **💡 选择建议** +> 如果你只需要个人密码管理,NodeWarden 足够使用且部署更简单。 +> 如果需要团队功能或高级特性,建议使用 [Vaultwarden](https://github.com/dani-garcia/vaultwarden)。 + +--- + +## 更新指南 + +如果你通过一键部署按钮安装,代码会被 fork 到你的 GitHub 账户。要获取最新更新: + +### 方法 1:手动同步(推荐) + +```bash +# 在你的 fork 仓库中 +git remote add upstream https://github.com/shuaiplus/nodewarden.git +git fetch upstream +git merge upstream/main +git push origin main +``` + +### 方法 2:GitHub Actions 自动同步 + +项目已内置自动同步配置,在你的 fork 仓库中: + +1. 进入 **Actions** 标签页 +2. 如果看到提示"Workflows aren't being run on this forked repository",点击 **I understand my workflows, go ahead and enable them** +3. 自动同步将每天运行一次(UTC 时间凌晨 2 点) +4. 也可以点击 **Sync Fork with Upstream** → **Run workflow** 手动触发 + +> **⚠️ 注意**:如果你修改了代码,自动同步可能会产生冲突,需要手动解决。 + +--- + +## 限制(本人认为完全没必要的功能) + +- 不支持两步验证 +- 不支持组织/团队功能 +- 不支持修改主密码 +- 文件附件大小限制 100MB + +--- + +## 技术栈 + +- **运行环境**:Cloudflare Workers +- **数据存储**:Cloudflare KV +- **文件存储**:Cloudflare R2 +- **开发语言**:TypeScript +- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256 + +--- + +## 常见问题 + +**Q: 如何备份数据?** +A: 在客户端中选择「导出密码库」,保存 JSON 文件。 + +**Q: 忘记主密码怎么办?** +A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 + +**Q: 可以多人使用吗?** +A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。 + +--- + +## 开源协议 + +MIT License + +--- + +## 致谢 + +- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端 +- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考 +- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 \ No newline at end of file diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..624e7d0 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,200 @@ +# NodeWarden + +An alternative implementation of the Bitwarden server API running on Cloudflare Workers, designed for personal use. + +English | [中文](./README.md) + +--- + +## ⚠️ Important Notice + + +> **Disclaimer** +> This project is for educational purposes only. We are not responsible for any data loss. Regular backups are strongly recommended. +> This project is not associated with Bitwarden. Do not report issues to Bitwarden's official support channels. + +--- + +## Features + +- ✅ Full password, note, card, and identity management +- ✅ Folders and favorites +- ✅ File attachments (R2 storage, 100MB limit) +- ✅ Import/Export functionality +- ✅ Website icons +- ✅ Login rate limiting (lockout after 5 failed attempts for 15 minutes) +- ✅ API rate limiting (60 requests/minute) +- ✅ End-to-end encryption (server cannot access plaintext) +- ✅ Compatible with all official Bitwarden clients + +--- + +## Quick Start + +### One-Click Deploy + +Click the button below to deploy to Cloudflare Workers: + +[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) + +**Deployment Steps:** + +1. Sign in with GitHub and authorize +2. Log in to your Cloudflare account +3. **Important**: Set `JWT_SECRET` to a strong random string (use `openssl rand -hex 32`) +4. KV storage and R2 bucket will be auto-provisioned +5. Click Deploy and wait for completion + +> ⚠️ **Reminder**: Always use a strong random `JWT_SECRET`. Never use example values or simple strings! + +### Client Setup + +After deployment, open any Bitwarden client: + +1. Click Settings (⚙️) +2. Select "Self-hosted environment" +3. Enter Server URL: `https://your-project.workers.dev` +4. Save and return to login page + +**First-time registration**: Visit your Workers URL directly to register an account. + +--- + +## Manual Deployment + +```bash +# Clone +git clone https://github.com/shuaiplus/nodewarden.git +cd nodewarden + +# Install +npm install + +# Login to Cloudflare +npx wrangler login + +# Create KV storage +npx wrangler kv namespace create VAULT +# Copy the id to wrangler.toml [[kv_namespaces]] + +# Create R2 bucket (for file attachments) +npx wrangler r2 bucket create nodewarden-attachments + +# Set JWT secret (use a strong random string) +npx wrangler secret put JWT_SECRET +# Recommended: openssl rand -hex 32 + +# Deploy +npm run deploy +``` + +--- + +## NodeWarden vs Vaultwarden + +NodeWarden focuses on **personal users** with core features, keeping the codebase minimal. Here's a comparison with Vaultwarden: + +| Feature | NodeWarden | Vaultwarden | Notes | +|---------|:----------:|:-----------:|-------| +| Passwords/Notes/Cards/Identity | ✅ | ✅ | Full support | +| Folders & Favorites | ✅ | ✅ | Full support | +| File Attachments | ✅ | ✅ | R2 storage, 100MB limit | +| Import/Export | ✅ | ✅ | Full support | +| Website Icons | ✅ | ✅ | Proxy fetch | +| Login Rate Limiting | ✅ | ✅ | Brute-force protection | +| Single User Mode | ✅ | ✅ | Personal use | +| Bitwarden Send | ❌ | ✅ | Secure sharing | +| Two-Factor Auth (2FA) | ❌ | ✅ | TOTP/WebAuthn etc | +| Emergency Access | ❌ | ✅ | Emergency contacts | +| Organizations/Teams | ❌ | ✅ | Multi-user collaboration | +| Real-time Sync (WebSocket) | ❌ | ✅ | Instant multi-device push | +| Email Notifications | ❌ | ✅ | Requires SMTP | +| Change Master Password | ❌ | ✅ | Re-encrypt vault | +| Admin Panel | ❌ | ✅ | Backend management | + +> **💡 Recommendation** +> If you only need personal password management, NodeWarden is sufficient and easier to deploy. +> For team features or advanced capabilities, consider [Vaultwarden](https://github.com/dani-garcia/vaultwarden). + +--- + +## Update Guide + +If you deployed via the one-click button, the code is forked to your GitHub account. To get the latest updates: + +### Method 1: Manual Sync (Recommended) + +```bash +# In your forked repository +git remote add upstream https://github.com/shuaiplus/nodewarden.git +git fetch upstream +git merge upstream/main +git push origin main +``` + +### Method 2: GitHub Actions Auto-Sync + +The project includes built-in auto-sync configuration. In your forked repository: + +1. Go to the **Actions** tab +2. If you see "Workflows aren't being run on this forked repository", click **I understand my workflows, go ahead and enable them** +3. Auto-sync will run daily at 2:00 AM UTC +4. You can also manually trigger by clicking **Sync Fork with Upstream** → **Run workflow** + +> **⚠️ Note**: If you've modified the code, auto-sync may cause merge conflicts that require manual resolution. + +--- + +## Limitations + +- Single user only (personal use) +- No two-factor authentication +- No organization/team support +- Cannot change master password +- File attachment size limit: 100MB + +--- + +## Tech Stack + +- **Runtime**: Cloudflare Workers +- **Data Storage**: Cloudflare KV +- **File Storage**: Cloudflare R2 +- **Language**: TypeScript +- **Encryption**: Client-side AES-256-CBC, JWT with HS256 + +--- + +## Security Recommendations + +1. **Strong JWT_SECRET**: Generate with `openssl rand -hex 32` +2. **Regular Backups**: Export your vault and store securely +3. **HTTPS Access**: Cloudflare Workers provides HTTPS by default +4. **Access Control**: Use Cloudflare WAF rules or IP whitelist + +--- + +## FAQ + +**Q: How to backup data?** +A: In the client, select "Export Vault" and save the JSON file. + +**Q: Forgot master password?** +A: Cannot be recovered due to end-to-end encryption. Keep your master password safe. + +**Q: Can multiple people use it?** +A: Not recommended. This project is designed for single user. Use Vaultwarden for multi-user scenarios. + +--- + +## License + +MIT License + +--- + +## Acknowledgments + +- [Bitwarden](https://bitwarden.com/) - Original design and clients +- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - Server implementation reference +- [Cloudflare Workers](https://workers.cloudflare.com/) - Serverless platform diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d4705f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1531 @@ +{ + "name": "nodewarden", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nodewarden", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@cloudflare/workers-types": "^4.20260131.0", + "typescript": "^5.9.3", + "wrangler": "^4.61.1" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.12.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.12.0.tgz", + "integrity": "sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": "^1.20260115.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260128.0.tgz", + "integrity": "sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260128.0.tgz", + "integrity": "sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260128.0.tgz", + "integrity": "sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260128.0.tgz", + "integrity": "sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260128.0.tgz", + "integrity": "sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260131.0", + "resolved": "https://registry.npmmirror.com/@cloudflare/workers-types/-/workers-types-4.20260131.0.tgz", + "integrity": "sha512-ELgvb2mp68Al50p+FmpgCO2hgU5o4tmz8pi7kShN+cRXc0UZoEdxpDIikR0CeT7b3tV7wlnEnsUzd0UoJLS0oQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peer": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmmirror.com/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.14", + "resolved": "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.14.tgz", + "integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/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/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260128.0", + "resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260128.0.tgz", + "integrity": "sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "^0.34.5", + "undici": "7.18.2", + "workerd": "1.20260128.0", + "ws": "8.18.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/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/undici": { + "version": "7.18.2", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.18.2.tgz", + "integrity": "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260128.0", + "resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260128.0.tgz", + "integrity": "sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260128.0", + "@cloudflare/workerd-darwin-arm64": "1.20260128.0", + "@cloudflare/workerd-linux-64": "1.20260128.0", + "@cloudflare/workerd-linux-arm64": "1.20260128.0", + "@cloudflare/workerd-windows-64": "1.20260128.0" + } + }, + "node_modules/wrangler": { + "version": "4.61.1", + "resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.61.1.tgz", + "integrity": "sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.12.0", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.0", + "miniflare": "4.20260128.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260128.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260128.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmmirror.com/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmmirror.com/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5513a36 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "nodewarden", + "version": "1.0.0", + "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", + "author": "shuaiplus", + "license": "MIT", + "main": "src/index.ts", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "keywords": [ + "bitwarden", + "vaultwarden", + "cloudflare", + "workers", + "password-manager" + ], + "cloudflare": { + "bindings": { + "JWT_SECRET": { + "description": "用于签名 JWT 的密钥。请输入一个随机的复杂字符串(建议 32 位以上)" + }, + "VAULT": { + "description": "用于存储密码库数据的 KV 存储" + }, + "ATTACHMENTS": { + "description": "用于存储文件附件的 R2 存储桶" + } + } + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260131.0", + "typescript": "^5.9.3", + "wrangler": "^4.61.1" + } +} + diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts new file mode 100644 index 0000000..c448d4f --- /dev/null +++ b/src/handlers/accounts.ts @@ -0,0 +1,205 @@ +import { Env, User, ProfileResponse } from '../types'; +import { StorageService } from '../services/storage'; +import { AuthService } from '../services/auth'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; + +// POST /api/accounts/register (only used from setup page, not client) +export async function handleRegister(request: Request, env: Env): Promise { + const storage = new StorageService(env.VAULT); + + // Check if already registered + const isRegistered = await storage.isRegistered(); + if (isRegistered) { + return errorResponse('Registration is closed', 403); + } + + let body: { + email?: string; + name?: string; + masterPasswordHash?: string; + masterPasswordHint?: string; + key?: string; + kdf?: number; + kdfIterations?: number; + kdfMemory?: number; + kdfParallelism?: number; + keys?: { + publicKey?: string; + encryptedPrivateKey?: string; + }; + }; + + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const email = body.email?.toLowerCase(); + const name = body.name || email; + const masterPasswordHash = body.masterPasswordHash; + const key = body.key; + const privateKey = body.keys?.encryptedPrivateKey; + const publicKey = body.keys?.publicKey; + + if (!email || !masterPasswordHash || !key) { + return errorResponse('Email, masterPasswordHash, and key are required', 400); + } + + if (!privateKey || !publicKey) { + return errorResponse('Private key and public key are required', 400); + } + + // Create user + const user: User = { + id: generateUUID(), + email: email, + name: name || email, + masterPasswordHash: masterPasswordHash, + key: key, + privateKey: privateKey, + publicKey: publicKey, + kdfType: body.kdf ?? 0, + kdfIterations: body.kdfIterations ?? 600000, + kdfMemory: body.kdfMemory, + kdfParallelism: body.kdfParallelism, + securityStamp: generateUUID(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await storage.saveUser(user); + await storage.setRegistered(); + + return jsonResponse({ success: true }, 200); +} + +// GET /api/accounts/profile +export async function handleGetProfile(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const user = await storage.getUserById(userId); + + if (!user) { + return errorResponse('User not found', 404); + } + + const profile: ProfileResponse = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: true, + premium: true, + premiumFromOrganization: false, + usesKeyConnector: false, + masterPasswordHint: null, + culture: 'en-US', + twoFactorEnabled: false, + key: user.key, + privateKey: user.privateKey, + securityStamp: user.securityStamp || user.id, + organizations: [], + providers: [], + providerOrganizations: [], + forcePasswordReset: false, + avatarColor: null, + creationDate: user.createdAt, + object: 'profile', + }; + + return jsonResponse(profile); +} + +// PUT /api/accounts/profile +export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const user = await storage.getUserById(userId); + + if (!user) { + return errorResponse('User not found', 404); + } + + let body: { name?: string; masterPasswordHint?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (body.name) { + user.name = body.name; + } + user.updatedAt = new Date().toISOString(); + + await storage.saveUser(user); + + return handleGetProfile(request, env, userId); +} + +// POST /api/accounts/keys +export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const user = await storage.getUserById(userId); + + if (!user) { + return errorResponse('User not found', 404); + } + + let body: { + key?: string; + encryptedPrivateKey?: string; + publicKey?: string; + }; + + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (body.key) user.key = body.key; + if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey; + if (body.publicKey) user.publicKey = body.publicKey; + user.updatedAt = new Date().toISOString(); + + await storage.saveUser(user); + + return handleGetProfile(request, env, userId); +} + +// GET /api/accounts/revision-date +export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const revisionDate = await storage.getRevisionDate(userId); + + // Return as milliseconds timestamp (Bitwarden format) + const timestamp = new Date(revisionDate).getTime(); + return jsonResponse(timestamp); +} + +// POST /api/accounts/verify-password +export async function handleVerifyPassword(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const user = await storage.getUserById(userId); + + if (!user) { + return errorResponse('User not found', 404); + } + + let body: { masterPasswordHash?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.masterPasswordHash) { + return errorResponse('masterPasswordHash is required', 400); + } + + if (body.masterPasswordHash !== user.masterPasswordHash) { + return errorResponse('Invalid password', 400); + } + + return new Response(null, { status: 200 }); +} diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts new file mode 100644 index 0000000..980a95e --- /dev/null +++ b/src/handlers/attachments.ts @@ -0,0 +1,351 @@ +import { Env, Attachment, Cipher } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; +import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt'; + +// Format file size to human readable +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Bytes`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +// Get R2 object path for attachment +function getAttachmentPath(cipherId: string, attachmentId: string): string { + return `${cipherId}/${attachmentId}`; +} + +// POST /api/ciphers/{cipherId}/attachment/v2 +// Creates attachment metadata and returns upload URL +export async function handleCreateAttachment( + request: Request, + env: Env, + userId: string, + cipherId: string +): Promise { + const storage = new StorageService(env.VAULT); + + // Verify cipher exists and belongs to user + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + let body: { + fileName?: string; + key?: string; + fileSize?: number; + }; + + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.fileName || !body.key) { + return errorResponse('fileName and key are required', 400); + } + + const fileSize = body.fileSize || 0; + const attachmentId = generateUUID(); + + // Create attachment metadata + const attachment: Attachment = { + id: attachmentId, + cipherId: cipherId, + fileName: body.fileName, + size: fileSize, + sizeName: formatSize(fileSize), + key: body.key, + }; + + // Save attachment metadata + await storage.saveAttachment(attachment); + + // Add attachment to cipher + await storage.addAttachmentToCipher(cipherId, attachmentId); + + // Update cipher revision date + await storage.updateCipherRevisionDate(cipherId); + + // Get updated cipher for response + const updatedCipher = await storage.getCipher(cipherId); + const attachments = await storage.getAttachmentsByCipher(cipherId); + + return jsonResponse({ + object: 'attachment-fileUpload', + attachmentId: attachmentId, + url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`, + fileUploadType: 0, // Direct upload + cipherResponse: formatCipherResponse(updatedCipher!, attachments), + }); +} + +// Maximum file size: 100MB +const MAX_FILE_SIZE = 100 * 1024 * 1024; + +// POST /api/ciphers/{cipherId}/attachment/{attachmentId} +// Upload attachment file content +export async function handleUploadAttachment( + request: Request, + env: Env, + userId: string, + cipherId: string, + attachmentId: string +): Promise { + const storage = new StorageService(env.VAULT); + + // Verify cipher exists and belongs to user + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + // Verify attachment exists + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); + } + + // Check content-length header for size limit + const contentLength = request.headers.get('content-length'); + if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) { + return errorResponse('File too large. Maximum size is 100MB', 413); + } + + // Get the file from multipart form data + const contentType = request.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + return errorResponse('Content-Type must be multipart/form-data', 400); + } + + const formData = await request.formData(); + const file = formData.get('data') as File | null; + + if (!file) { + return errorResponse('No file uploaded', 400); + } + + // Check actual file size + if (file.size > MAX_FILE_SIZE) { + return errorResponse('File too large. Maximum size is 100MB', 413); + } + + // Store file in R2 + const path = getAttachmentPath(cipherId, attachmentId); + await env.ATTACHMENTS.put(path, file.stream(), { + httpMetadata: { + contentType: 'application/octet-stream', + }, + customMetadata: { + cipherId: cipherId, + attachmentId: attachmentId, + }, + }); + + // Update attachment size if different + const actualSize = file.size; + if (actualSize !== attachment.size) { + attachment.size = actualSize; + attachment.sizeName = formatSize(actualSize); + await storage.saveAttachment(attachment); + } + + // Update cipher revision date + await storage.updateCipherRevisionDate(cipherId); + + return new Response(null, { status: 200 }); +} + +// GET /api/ciphers/{cipherId}/attachment/{attachmentId} +// Get attachment download info +export async function handleGetAttachment( + request: Request, + env: Env, + userId: string, + cipherId: string, + attachmentId: string +): Promise { + const storage = new StorageService(env.VAULT); + + // Verify cipher exists and belongs to user + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + // Verify attachment exists + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); + } + + // Generate short-lived download token + const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET); + + // Generate download URL with token + const url = new URL(request.url); + const downloadUrl = `${url.origin}/api/attachments/${cipherId}/${attachmentId}?token=${token}`; + + return jsonResponse({ + object: 'attachment', + id: attachment.id, + url: downloadUrl, + fileName: attachment.fileName, + key: attachment.key, + size: String(attachment.size), + sizeName: attachment.sizeName, + }); +} + +// GET /api/attachments/{cipherId}/{attachmentId}?token=xxx +// Public download endpoint (uses token for auth instead of header) +export async function handlePublicDownloadAttachment( + request: Request, + env: Env, + cipherId: string, + attachmentId: string +): Promise { + const url = new URL(request.url); + const token = url.searchParams.get('token'); + + if (!token) { + return errorResponse('Token required', 401); + } + + // Verify token + const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); + if (!claims) { + return errorResponse('Invalid or expired token', 401); + } + + // Verify token matches request + if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) { + return errorResponse('Token mismatch', 401); + } + + const storage = new StorageService(env.VAULT); + + // Verify attachment exists + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); + } + + // Get file from R2 + const path = getAttachmentPath(cipherId, attachmentId); + const object = await env.ATTACHMENTS.get(path); + + if (!object) { + return errorResponse('Attachment file not found', 404); + } + + return new Response(object.body, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'private, no-cache', + 'Access-Control-Allow-Origin': '*', + }, + }); +} + +// DELETE /api/ciphers/{cipherId}/attachment/{attachmentId} +// Delete attachment +export async function handleDeleteAttachment( + request: Request, + env: Env, + userId: string, + cipherId: string, + attachmentId: string +): Promise { + const storage = new StorageService(env.VAULT); + + // Verify cipher exists and belongs to user + const cipher = await storage.getCipher(cipherId); + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + // Verify attachment exists + const attachment = await storage.getAttachment(attachmentId); + if (!attachment || attachment.cipherId !== cipherId) { + return errorResponse('Attachment not found', 404); + } + + // Delete file from R2 + const path = getAttachmentPath(cipherId, attachmentId); + await env.ATTACHMENTS.delete(path); + + // Delete attachment metadata + await storage.deleteAttachment(attachmentId); + + // Remove attachment from cipher + await storage.removeAttachmentFromCipher(cipherId, attachmentId); + + // Update cipher revision date + await storage.updateCipherRevisionDate(cipherId); + + // Get updated cipher for response + const updatedCipher = await storage.getCipher(cipherId); + const attachments = await storage.getAttachmentsByCipher(cipherId); + + return jsonResponse({ + cipher: formatCipherResponse(updatedCipher!, attachments), + }); +} + +// Helper: Format cipher response with attachments +function formatCipherResponse(cipher: Cipher, attachments: Attachment[]): any { + return { + id: cipher.id, + organizationId: null, + folderId: cipher.folderId, + type: cipher.type, + name: cipher.name, + notes: cipher.notes, + favorite: cipher.favorite, + login: cipher.login, + card: cipher.card, + identity: cipher.identity, + secureNote: cipher.secureNote, + fields: cipher.fields, + passwordHistory: cipher.passwordHistory, + reprompt: cipher.reprompt, + organizationUseTotp: false, + creationDate: cipher.createdAt, + revisionDate: cipher.updatedAt, + deletedDate: cipher.deletedAt, + edit: true, + viewPassword: true, + permissions: null, + object: 'cipher', + collectionIds: [], + attachments: attachments.length > 0 ? attachments.map(a => ({ + id: a.id, + fileName: a.fileName, + size: String(a.size), + sizeName: a.sizeName, + key: a.key, + object: 'attachment', + })) : null, + }; +} + +// Delete all attachments for a cipher (used when deleting cipher) +export async function deleteAllAttachmentsForCipher( + env: Env, + cipherId: string +): Promise { + const storage = new StorageService(env.VAULT); + const attachments = await storage.getAttachmentsByCipher(cipherId); + + for (const attachment of attachments) { + const path = getAttachmentPath(cipherId, attachment.id); + await env.ATTACHMENTS.delete(path); + await storage.deleteAttachment(attachment.id); + } +} diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts new file mode 100644 index 0000000..b0edbd3 --- /dev/null +++ b/src/handlers/ciphers.ts @@ -0,0 +1,278 @@ +import { Env, Cipher, CipherResponse, Attachment } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; +import { deleteAllAttachmentsForCipher } from './attachments'; + +// Format attachments for API response +function formatAttachments(attachments: Attachment[]): any[] | null { + if (attachments.length === 0) return null; + return attachments.map(a => ({ + id: a.id, + fileName: a.fileName, + size: String(a.size), + sizeName: a.sizeName, + key: a.key, + object: 'attachment', + })); +} + +// Convert internal cipher to API response format +function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { + return { + id: cipher.id, + organizationId: null, + folderId: cipher.folderId, + type: cipher.type, + name: cipher.name, + notes: cipher.notes, + favorite: cipher.favorite, + login: cipher.login, + card: cipher.card, + identity: cipher.identity, + secureNote: cipher.secureNote, + fields: cipher.fields, + passwordHistory: cipher.passwordHistory, + reprompt: cipher.reprompt, + organizationUseTotp: false, + creationDate: cipher.createdAt, + revisionDate: cipher.updatedAt, + deletedDate: cipher.deletedAt, + edit: true, + viewPassword: true, + permissions: { + delete: true, + restore: true, + edit: true, + }, + object: 'cipher', + collectionIds: [], + attachments: formatAttachments(attachments), + }; +} + +// GET /api/ciphers +export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const ciphers = await storage.getAllCiphers(userId); + + // Filter out soft-deleted ciphers unless specifically requested + const url = new URL(request.url); + const includeDeleted = url.searchParams.get('deleted') === 'true'; + + const filteredCiphers = includeDeleted + ? ciphers + : ciphers.filter(c => !c.deletedAt); + + // Get attachments for all ciphers + const cipherResponses = []; + for (const cipher of filteredCiphers) { + const attachments = await storage.getAttachmentsByCipher(cipher.id); + cipherResponses.push(cipherToResponse(cipher, attachments)); + } + + return jsonResponse({ + data: cipherResponses, + object: 'list', + continuationToken: null, + }); +} + +// GET /api/ciphers/:id +export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + const attachments = await storage.getAttachmentsByCipher(cipher.id); + return jsonResponse(cipherToResponse(cipher, attachments)); +} + +// POST /api/ciphers +export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + + let body: any; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + // Handle nested cipher object (from some clients) + const cipherData = body.cipher || body; + + const now = new Date().toISOString(); + const cipher: Cipher = { + id: generateUUID(), + userId: userId, + type: cipherData.type, + folderId: cipherData.folderId || null, + name: cipherData.name, + notes: cipherData.notes || null, + favorite: cipherData.favorite || false, + login: cipherData.login || null, + card: cipherData.card || null, + identity: cipherData.identity || null, + secureNote: cipherData.secureNote || null, + fields: cipherData.fields || null, + passwordHistory: cipherData.passwordHistory || null, + reprompt: cipherData.reprompt || 0, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + + await storage.saveCipher(cipher); + await storage.updateRevisionDate(userId); + + return jsonResponse(cipherToResponse(cipher), 200); +} + +// PUT /api/ciphers/:id +export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const existingCipher = await storage.getCipher(id); + + if (!existingCipher || existingCipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + let body: any; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + // Handle nested cipher object + const cipherData = body.cipher || body; + + const cipher: Cipher = { + ...existingCipher, + type: cipherData.type ?? existingCipher.type, + folderId: cipherData.folderId !== undefined ? cipherData.folderId : existingCipher.folderId, + name: cipherData.name ?? existingCipher.name, + notes: cipherData.notes !== undefined ? cipherData.notes : existingCipher.notes, + favorite: cipherData.favorite ?? existingCipher.favorite, + login: cipherData.login !== undefined ? cipherData.login : existingCipher.login, + card: cipherData.card !== undefined ? cipherData.card : existingCipher.card, + identity: cipherData.identity !== undefined ? cipherData.identity : existingCipher.identity, + secureNote: cipherData.secureNote !== undefined ? cipherData.secureNote : existingCipher.secureNote, + fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields, + passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory, + reprompt: cipherData.reprompt ?? existingCipher.reprompt, + updatedAt: new Date().toISOString(), + }; + + await storage.saveCipher(cipher); + await storage.updateRevisionDate(userId); + + return jsonResponse(cipherToResponse(cipher)); +} + +// DELETE /api/ciphers/:id +export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + // Soft delete + cipher.deletedAt = new Date().toISOString(); + cipher.updatedAt = cipher.deletedAt; + await storage.saveCipher(cipher); + await storage.updateRevisionDate(userId); + + return jsonResponse(cipherToResponse(cipher)); +} + +// DELETE /api/ciphers/:id (permanent) +export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + // Delete all attachments first + await deleteAllAttachmentsForCipher(env, id); + + await storage.deleteCipher(id, userId); + await storage.updateRevisionDate(userId); + + return new Response(null, { status: 204 }); +} + +// PUT /api/ciphers/:id/restore +export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + cipher.deletedAt = null; + cipher.updatedAt = new Date().toISOString(); + await storage.saveCipher(cipher); + await storage.updateRevisionDate(userId); + + return jsonResponse(cipherToResponse(cipher)); +} + +// PUT /api/ciphers/:id/partial - Update only favorite/folderId +export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const cipher = await storage.getCipher(id); + + if (!cipher || cipher.userId !== userId) { + return errorResponse('Cipher not found', 404); + } + + let body: { folderId?: string | null; favorite?: boolean }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (body.folderId !== undefined) { + cipher.folderId = body.folderId; + } + if (body.favorite !== undefined) { + cipher.favorite = body.favorite; + } + cipher.updatedAt = new Date().toISOString(); + + await storage.saveCipher(cipher); + await storage.updateRevisionDate(userId); + + return jsonResponse(cipherToResponse(cipher)); +} + +// POST/PUT /api/ciphers/move - Bulk move to folder +export async function handleBulkMoveCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + + let body: { ids?: string[]; folderId?: string | null }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId); + + return new Response(null, { status: 204 }); +} diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts new file mode 100644 index 0000000..14c2fee --- /dev/null +++ b/src/handlers/folders.ts @@ -0,0 +1,107 @@ +import { Env, Folder, FolderResponse } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; + +// Convert internal folder to API response format +function folderToResponse(folder: Folder): FolderResponse { + return { + id: folder.id, + name: folder.name, + revisionDate: folder.updatedAt, + object: 'folder', + }; +} + +// GET /api/folders +export async function handleGetFolders(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + const folders = await storage.getAllFolders(userId); + + return jsonResponse({ + data: folders.map(folderToResponse), + object: 'list', + continuationToken: null, + }); +} + +// GET /api/folders/:id +export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const folder = await storage.getFolder(id); + + if (!folder || folder.userId !== userId) { + return errorResponse('Folder not found', 404); + } + + return jsonResponse(folderToResponse(folder)); +} + +// POST /api/folders +export async function handleCreateFolder(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + + let body: { name?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.name) { + return errorResponse('Name is required', 400); + } + + const now = new Date().toISOString(); + const folder: Folder = { + id: generateUUID(), + userId: userId, + name: body.name, + createdAt: now, + updatedAt: now, + }; + + await storage.saveFolder(folder); + + return jsonResponse(folderToResponse(folder), 200); +} + +// PUT /api/folders/:id +export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const folder = await storage.getFolder(id); + + if (!folder || folder.userId !== userId) { + return errorResponse('Folder not found', 404); + } + + let body: { name?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (body.name) { + folder.name = body.name; + } + folder.updatedAt = new Date().toISOString(); + + await storage.saveFolder(folder); + + return jsonResponse(folderToResponse(folder)); +} + +// DELETE /api/folders/:id +export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise { + const storage = new StorageService(env.VAULT); + const folder = await storage.getFolder(id); + + if (!folder || folder.userId !== userId) { + return errorResponse('Folder not found', 404); + } + + await storage.deleteFolder(id, userId); + + return new Response(null, { status: 204 }); +} diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts new file mode 100644 index 0000000..4d24a3a --- /dev/null +++ b/src/handlers/identity.ts @@ -0,0 +1,168 @@ +import { Env, TokenResponse } from '../types'; +import { StorageService } from '../services/storage'; +import { AuthService } from '../services/auth'; +import { RateLimitService } from '../services/ratelimit'; +import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; + +// POST /identity/connect/token +export async function handleToken(request: Request, env: Env): Promise { + const storage = new StorageService(env.VAULT); + const auth = new AuthService(env); + const rateLimit = new RateLimitService(env.VAULT); + + let body: Record; + const contentType = request.headers.get('content-type') || ''; + + if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = await request.formData(); + body = Object.fromEntries(formData.entries()) as Record; + } else { + body = await request.json(); + } + + const grantType = body.grant_type; + + if (grantType === 'password') { + // Login with password + const email = body.username?.toLowerCase(); + const passwordHash = body.password; + + if (!email || !passwordHash) { + return errorResponse('Email and password are required', 400); + } + + // Check if login is rate limited + const loginCheck = await rateLimit.checkLoginAttempt(email); + if (!loginCheck.allowed) { + return errorResponse( + `Too many failed login attempts. Try again in ${Math.ceil(loginCheck.retryAfterSeconds! / 60)} minutes.`, + 429 + ); + } + + const user = await storage.getUser(email); + if (!user) { + // Record failed attempt even for non-existent user (prevent enumeration) + await rateLimit.recordFailedLogin(email); + return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); + } + + const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); + if (!valid) { + // Record failed login attempt + const result = await rateLimit.recordFailedLogin(email); + if (result.locked) { + return identityErrorResponse( + `Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`, + 'TooManyRequests', + 429 + ); + } + return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); + } + + // Successful login - clear failed attempts + await rateLimit.clearLoginAttempts(email); + + const accessToken = await auth.generateAccessToken(user); + const refreshToken = await auth.generateRefreshToken(user.id); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: 7200, + token_type: 'Bearer', + refresh_token: refreshToken, + Key: user.key, + PrivateKey: user.privateKey, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: { + HasMasterPassword: true, + Object: 'userDecryptionOptions', + }, + }; + + return jsonResponse(response); + + } else if (grantType === 'refresh_token') { + // Refresh token + const refreshToken = body.refresh_token; + if (!refreshToken) { + return errorResponse('Refresh token is required', 400); + } + + const result = await auth.refreshAccessToken(refreshToken); + if (!result) { + return errorResponse('Invalid refresh token', 401); + } + + // Revoke old refresh token (prevent reuse) + await storage.deleteRefreshToken(refreshToken); + + const { accessToken, user } = result; + const newRefreshToken = await auth.generateRefreshToken(user.id); + + const response: TokenResponse = { + access_token: accessToken, + expires_in: 7200, + token_type: 'Bearer', + refresh_token: newRefreshToken, + Key: user.key, + PrivateKey: user.privateKey, + Kdf: user.kdfType, + KdfIterations: user.kdfIterations, + KdfMemory: user.kdfMemory, + KdfParallelism: user.kdfParallelism, + ForcePasswordReset: false, + ResetMasterPassword: false, + scope: 'api offline_access', + unofficialServer: true, + UserDecryptionOptions: { + HasMasterPassword: true, + Object: 'userDecryptionOptions', + }, + }; + + return jsonResponse(response); + } + + return errorResponse('Unsupported grant type', 400); +} + +// POST /identity/accounts/prelogin +export async function handlePrelogin(request: Request, env: Env): Promise { + const storage = new StorageService(env.VAULT); + + let body: { email?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const email = body.email?.toLowerCase(); + if (!email) { + return errorResponse('Email is required', 400); + } + + const user = await storage.getUser(email); + + // Return default KDF settings even if user doesn't exist (to prevent user enumeration) + const kdfType = user?.kdfType ?? 0; + const kdfIterations = user?.kdfIterations ?? 600000; + const kdfMemory = user?.kdfMemory; + const kdfParallelism = user?.kdfParallelism; + + return jsonResponse({ + kdf: kdfType, + kdfIterations: kdfIterations, + kdfMemory: kdfMemory, + kdfParallelism: kdfParallelism, + }); +} diff --git a/src/handlers/import.ts b/src/handlers/import.ts new file mode 100644 index 0000000..f790517 --- /dev/null +++ b/src/handlers/import.ts @@ -0,0 +1,187 @@ +import { Env, Cipher, Folder, CipherType } from '../types'; +import { StorageService } from '../services/storage'; +import { errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; + +// Bitwarden client import request format +interface CiphersImportRequest { + ciphers: Array<{ + type: number; + name: string; + notes?: string | null; + favorite?: boolean; + reprompt?: number; + login?: { + uris?: Array<{ uri: string | null; match?: number | null }> | null; + username?: string | null; + password?: string | null; + totp?: string | null; + } | null; + card?: { + cardholderName?: string | null; + brand?: string | null; + number?: string | null; + expMonth?: string | null; + expYear?: string | null; + code?: string | null; + } | null; + identity?: { + title?: string | null; + firstName?: string | null; + middleName?: string | null; + lastName?: string | null; + address1?: string | null; + address2?: string | null; + address3?: string | null; + city?: string | null; + state?: string | null; + postalCode?: string | null; + country?: string | null; + company?: string | null; + email?: string | null; + phone?: string | null; + ssn?: string | null; + username?: string | null; + passportNumber?: string | null; + licenseNumber?: string | null; + } | null; + secureNote?: { type: number } | null; + fields?: Array<{ + name?: string | null; + value?: string | null; + type: number; + linkedId?: number | null; + }> | null; + passwordHistory?: Array<{ + password: string; + lastUsedDate: string; + }> | null; + }>; + folders: Array<{ + name: string; + }>; + folderRelationships: Array<{ + key: number; // cipher index + value: number; // folder index + }>; +} + +// POST /api/ciphers/import - Bitwarden client import endpoint +export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + + let importData: CiphersImportRequest; + try { + importData = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const folders = importData.folders || []; + const ciphers = importData.ciphers || []; + const folderRelationships = importData.folderRelationships || []; + + const now = new Date().toISOString(); + + // Create folders and build index -> id mapping + const folderIdMap = new Map(); + + for (let i = 0; i < folders.length; i++) { + const folderId = generateUUID(); + folderIdMap.set(i, folderId); + + const folder: Folder = { + id: folderId, + userId: userId, + name: folders[i].name, + createdAt: now, + updatedAt: now, + }; + + await storage.saveFolder(folder); + } + + // Build cipher index -> folder id mapping from relationships + const cipherFolderMap = new Map(); + for (const rel of folderRelationships) { + const folderId = folderIdMap.get(rel.value); + if (folderId) { + cipherFolderMap.set(rel.key, folderId); + } + } + + // Create ciphers + for (let i = 0; i < ciphers.length; i++) { + const c = ciphers[i]; + const folderId = cipherFolderMap.get(i) || null; + + const cipher: Cipher = { + id: generateUUID(), + userId: userId, + type: c.type as CipherType, + folderId: folderId, + name: c.name || 'Untitled', + notes: c.notes || null, + favorite: c.favorite || false, + login: c.login ? { + username: c.login.username || null, + password: c.login.password || null, + uris: c.login.uris?.map(u => ({ + uri: u.uri || null, + uriChecksum: null, + match: u.match ?? null, + })) || null, + totp: c.login.totp || null, + autofillOnPageLoad: null, + fido2Credentials: null, + } : null, + card: c.card ? { + cardholderName: c.card.cardholderName || null, + brand: c.card.brand || null, + number: c.card.number || null, + expMonth: c.card.expMonth || null, + expYear: c.card.expYear || null, + code: c.card.code || null, + } : null, + identity: c.identity ? { + title: c.identity.title || null, + firstName: c.identity.firstName || null, + middleName: c.identity.middleName || null, + lastName: c.identity.lastName || null, + address1: c.identity.address1 || null, + address2: c.identity.address2 || null, + address3: c.identity.address3 || null, + city: c.identity.city || null, + state: c.identity.state || null, + postalCode: c.identity.postalCode || null, + country: c.identity.country || null, + company: c.identity.company || null, + email: c.identity.email || null, + phone: c.identity.phone || null, + ssn: c.identity.ssn || null, + username: c.identity.username || null, + passportNumber: c.identity.passportNumber || null, + licenseNumber: c.identity.licenseNumber || null, + } : null, + secureNote: c.secureNote || null, + fields: c.fields?.map(f => ({ + name: f.name || null, + value: f.value || null, + type: f.type, + linkedId: f.linkedId ?? null, + })) || null, + passwordHistory: c.passwordHistory || null, + reprompt: c.reprompt || 0, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; + + await storage.saveCipher(cipher); + } + + // Update revision date + await storage.updateRevisionDate(userId); + + return new Response(null, { status: 200 }); +} diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts new file mode 100644 index 0000000..fc42c6f --- /dev/null +++ b/src/handlers/setup.ts @@ -0,0 +1,709 @@ +import { Env } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, htmlResponse, errorResponse } from '../utils/response'; + +// Setup page HTML (single-file, no external assets) +const setupPageHTML = ` + + + + + NodeWarden + + + +
+ +
+ + + +`; + +// GET / - Setup page +export async function handleSetupPage(request: Request, env: Env): Promise { + return htmlResponse(setupPageHTML); +} + +// GET /setup/status +export async function handleSetupStatus(request: Request, env: Env): Promise { + const storage = new StorageService(env.VAULT); + const registered = await storage.isRegistered(); + return jsonResponse({ registered }); +} diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts new file mode 100644 index 0000000..e0a7e24 --- /dev/null +++ b/src/handlers/sync.ts @@ -0,0 +1,114 @@ +import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse, Attachment } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; + +// Format attachments for API response +function formatAttachments(attachments: Attachment[]): any[] | null { + if (attachments.length === 0) return null; + return attachments.map(a => ({ + id: a.id, + fileName: a.fileName, + size: String(a.size), + sizeName: a.sizeName, + key: a.key, + object: 'attachment', + })); +} + +// GET /api/sync +export async function handleSync(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.VAULT); + + const user = await storage.getUserById(userId); + if (!user) { + return errorResponse('User not found', 404); + } + + const ciphers = await storage.getAllCiphers(userId); + const folders = await storage.getAllFolders(userId); + + // Build profile response + const profile: ProfileResponse = { + id: user.id, + name: user.name, + email: user.email, + emailVerified: true, + premium: true, + premiumFromOrganization: false, + usesKeyConnector: false, + masterPasswordHint: null, + culture: 'en-US', + twoFactorEnabled: false, + key: user.key, + privateKey: user.privateKey, + securityStamp: user.securityStamp || user.id, + organizations: [], + providers: [], + providerOrganizations: [], + forcePasswordReset: false, + avatarColor: null, + creationDate: user.createdAt, + object: 'profile', + }; + + // Build cipher responses with attachments + const cipherResponses: CipherResponse[] = []; + for (const cipher of ciphers) { + const attachments = await storage.getAttachmentsByCipher(cipher.id); + cipherResponses.push({ + id: cipher.id, + organizationId: null, + folderId: cipher.folderId, + type: cipher.type, + name: cipher.name, + notes: cipher.notes, + favorite: cipher.favorite, + login: cipher.login, + card: cipher.card, + identity: cipher.identity, + secureNote: cipher.secureNote, + fields: cipher.fields, + passwordHistory: cipher.passwordHistory, + reprompt: cipher.reprompt, + organizationUseTotp: false, + creationDate: cipher.createdAt, + revisionDate: cipher.updatedAt, + deletedDate: cipher.deletedAt, + edit: true, + viewPassword: true, + permissions: { + delete: true, + restore: true, + edit: true, + }, + object: 'cipher', + collectionIds: [], + attachments: formatAttachments(attachments), + }); + }; + + // Build folder responses + const folderResponses: FolderResponse[] = folders.map(folder => ({ + id: folder.id, + name: folder.name, + revisionDate: folder.updatedAt, + object: 'folder', + })); + + const syncResponse: SyncResponse = { + profile: profile, + folders: folderResponses, + collections: [], + ciphers: cipherResponses, + domains: { + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }, + policies: [], + sends: [], + object: 'sync', + }; + + return jsonResponse(syncResponse); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..64a8284 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,18 @@ +import { Env } from './types'; +import { handleRequest } from './router'; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + // Security check: JWT_SECRET must be set + if (!env.JWT_SECRET) { + return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 }); + } + + // Security check: warn if JWT_SECRET is too weak + if (env.JWT_SECRET.length < 32) { + console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security'); + } + + return handleRequest(request, env); + }, +}; diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..81c00a7 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,398 @@ +import { Env } from './types'; +import { AuthService } from './services/auth'; +import { RateLimitService, getClientIdentifier } from './services/ratelimit'; +import { handleCors, errorResponse, jsonResponse } from './utils/response'; + +// Identity handlers +import { handleToken, handlePrelogin } from './handlers/identity'; + +// Account handlers +import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts'; + +// Cipher handlers +import { + handleGetCiphers, + handleGetCipher, + handleCreateCipher, + handleUpdateCipher, + handleDeleteCipher, + handlePermanentDeleteCipher, + handleRestoreCipher, + handlePartialUpdateCipher, + handleBulkMoveCiphers, +} from './handlers/ciphers'; + +// Folder handlers +import { + handleGetFolders, + handleGetFolder, + handleCreateFolder, + handleUpdateFolder, + handleDeleteFolder +} from './handlers/folders'; + +// Sync handler +import { handleSync } from './handlers/sync'; + +// Setup handlers +import { handleSetupPage, handleSetupStatus } from './handlers/setup'; + +// Import handler +import { handleCiphersImport } from './handlers/import'; + +// Attachment handlers +import { + handleCreateAttachment, + handleUploadAttachment, + handleGetAttachment, + handleDeleteAttachment, + handlePublicDownloadAttachment, +} from './handlers/attachments'; + +// Icons handler - proxy to Bitwarden's official icon service +async function handleGetIcon(request: Request, env: Env, hostname: string): Promise { + try { + // Use Bitwarden's official icon service + const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`; + const resp = await fetch(iconUrl, { + headers: { 'User-Agent': 'NodeWarden/1.0' }, + redirect: 'follow', + }); + + if (resp.ok) { + const body = await resp.arrayBuffer(); + return new Response(body, { + status: 200, + headers: { + 'Content-Type': resp.headers.get('Content-Type') || 'image/png', + 'Cache-Control': 'public, max-age=604800', // 7 days + 'Access-Control-Allow-Origin': '*', + }, + }); + } + + return new Response(null, { status: 204 }); + } catch { + return new Response(null, { status: 204 }); + } +} + +export async function handleRequest(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + + // Handle CORS preflight + if (method === 'OPTIONS') { + return handleCors(); + } + + // Route matching + try { + // Setup page (root) + if (path === '/' && method === 'GET') { + return handleSetupPage(request, env); + } + + // Setup status + if (path === '/setup/status' && method === 'GET') { + return handleSetupStatus(request, env); + } + + // Favicon - return empty + if (path === '/favicon.ico') { + return new Response(null, { status: 204 }); + } + + // Icon endpoint - proxy to Bitwarden's icon service (no auth required) + const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); + if (iconMatch) { + const hostname = iconMatch[1]; + return handleGetIcon(request, env, hostname); + } + + // Public attachment download (no auth header, uses token in query string) + const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); + if (publicAttachmentMatch && method === 'GET') { + const cipherId = publicAttachmentMatch[1]; + const attachmentId = publicAttachmentMatch[2]; + return handlePublicDownloadAttachment(request, env, cipherId, attachmentId); + } + + // Notifications hub (stub - no auth required, return 200 for connection) + if (path.startsWith('/notifications/')) { + return new Response(null, { status: 200 }); + } + + // Known device check (no auth required) - returns plain string "true" or "false" + if (path.startsWith('/api/devices/knowndevice')) { + return new Response('true', { + headers: { + 'Content-Type': 'text/plain', + }, + }); + } + + // Identity endpoints (no auth required) + if (path === '/identity/connect/token' && method === 'POST') { + return handleToken(request, env); + } + + if (path === '/identity/accounts/prelogin' && method === 'POST') { + return handlePrelogin(request, env); + } + + // Config endpoint (no auth required for basic config) + // Bitwarden clients call GET "/config" (relative to the API base URL). + // They also tolerate different casing, but their response models use PascalCase. + const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET'; + if (isConfigRequest) { + const origin = url.origin; + return jsonResponse({ + version: '2025.12.0', + gitHash: 'nodewarden', + server: null, + environment: { + vault: origin, + api: origin + '/api', + identity: origin + '/identity', + notifications: origin + '/notifications', + sso: '', + }, + featureStates: { + 'duo-redirect': true, + }, + object: 'config', + }); + } + + // Version endpoint (some clients probe this to validate the server) + if (path === '/api/version' && method === 'GET') { + return jsonResponse('2025.12.0'); + } + + // Registration endpoint (no auth required, but only works once) + if (path === '/api/accounts/register' && method === 'POST') { + return handleRegister(request, env); + } + + // All other API endpoints require authentication + const auth = new AuthService(env); + const authHeader = request.headers.get('Authorization'); + const payload = await auth.verifyAccessToken(authHeader); + + if (!payload) { + return errorResponse('Unauthorized', 401); + } + + const userId = payload.sub; + + // API rate limiting for authenticated requests + const rateLimit = new RateLimitService(env.VAULT); + const clientId = getClientIdentifier(request); + const rateLimitCheck = await rateLimit.checkApiRateLimit(userId + ':' + clientId); + + if (!rateLimitCheck.allowed) { + return new Response(JSON.stringify({ + error: 'Too many requests', + error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`, + }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(), + 'X-RateLimit-Remaining': '0', + }, + }); + } + + // Increment rate limit counter + await rateLimit.incrementApiCount(userId + ':' + clientId); + + // Account endpoints + if (path === '/api/accounts/profile') { + if (method === 'GET') return handleGetProfile(request, env, userId); + if (method === 'PUT') return handleUpdateProfile(request, env, userId); + } + + if (path === '/api/accounts/keys' && method === 'POST') { + return handleSetKeys(request, env, userId); + } + + // Revision date endpoint + if (path === '/api/accounts/revision-date' && method === 'GET') { + return handleGetRevisionDate(request, env, userId); + } + + // Verify password endpoint + if (path === '/api/accounts/verify-password' && method === 'POST') { + return handleVerifyPassword(request, env, userId); + } + + // Sync endpoint + if (path === '/api/sync' && method === 'GET') { + return handleSync(request, env, userId); + } + + // Cipher endpoints + if (path === '/api/ciphers' || path === '/api/ciphers/create') { + if (method === 'GET') return handleGetCiphers(request, env, userId); + if (method === 'POST') return handleCreateCipher(request, env, userId); + } + + // Ciphers import endpoint (Bitwarden client format) + if (path === '/api/ciphers/import' && method === 'POST') { + return handleCiphersImport(request, env, userId); + } + + // Bulk cipher operations (only move is allowed) + if (path === '/api/ciphers/move') { + if (method === 'POST' || method === 'PUT') { + return handleBulkMoveCiphers(request, env, userId); + } + } + + // Match /api/ciphers/:id patterns + const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i); + if (cipherMatch) { + const cipherId = cipherMatch[1]; + const subPath = cipherMatch[2] || ''; + + if (subPath === '' || subPath === '/') { + if (method === 'GET') return handleGetCipher(request, env, userId, cipherId); + if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId); + if (method === 'DELETE') return handleDeleteCipher(request, env, userId, cipherId); + } + + if (subPath === '/delete' && method === 'PUT') { + return handleDeleteCipher(request, env, userId, cipherId); + } + + if (subPath === '/delete' && method === 'DELETE') { + return handlePermanentDeleteCipher(request, env, userId, cipherId); + } + + if (subPath === '/restore' && method === 'PUT') { + return handleRestoreCipher(request, env, userId, cipherId); + } + + if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) { + return handlePartialUpdateCipher(request, env, userId, cipherId); + } + + // Share endpoint - just return the cipher (single user mode) + if (subPath === '/share' && method === 'POST') { + return handleGetCipher(request, env, userId, cipherId); + } + + if (subPath === '/details' && method === 'GET') { + return handleGetCipher(request, env, userId, cipherId); + } + + // Attachment endpoints + // POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata + if (subPath === '/attachment/v2' && method === 'POST') { + return handleCreateAttachment(request, env, userId, cipherId); + } + + // Legacy attachment endpoint - also goes to v2 flow + if (subPath === '/attachment' && method === 'POST') { + return handleCreateAttachment(request, env, userId, cipherId); + } + + // Match /api/ciphers/{id}/attachment/{attachmentId} + const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i); + if (attachmentMatch) { + const attachmentId = attachmentMatch[1]; + if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId); + if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId); + if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); + } + + // DELETE via POST (legacy) + const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i); + if (attachmentDeleteMatch && method === 'POST') { + const attachmentId = attachmentDeleteMatch[1]; + return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); + } + } + + // Folder endpoints + if (path === '/api/folders') { + if (method === 'GET') return handleGetFolders(request, env, userId); + if (method === 'POST') return handleCreateFolder(request, env, userId); + } + + // Match /api/folders/:id patterns + const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i); + if (folderMatch) { + const folderId = folderMatch[1]; + if (method === 'GET') return handleGetFolder(request, env, userId, folderId); + if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId); + if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); + } + + // Auth requests endpoint (stub - we don't support passwordless login) + if (path.startsWith('/api/auth-requests')) { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + + // Collections endpoint (stub - no organization support) + if (path === '/api/collections' || path.startsWith('/api/collections/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + } + + // Organizations endpoint (stub - no organization support) + if (path === '/api/organizations' || path.startsWith('/api/organizations/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + } + + // Sends endpoint (stub - not implemented) + if (path === '/api/sends' || path.startsWith('/api/sends/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + } + + // Policies endpoint (stub - not implemented) + if (path === '/api/policies' || path.startsWith('/api/policies/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + } + + // Settings domains endpoint (stub) + if (path === '/api/settings/domains') { + if (method === 'GET') { + return jsonResponse({ + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }); + } + if (method === 'PUT' || method === 'POST') { + return jsonResponse({ + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }); + } + } + + // Devices endpoint (stub) - for authenticated requests + if (path === '/api/devices' && method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + + // Not found + return errorResponse('Not found', 404); + + } catch (error) { + console.error('Request error:', error); + return errorResponse('Internal server error', 500); + } +} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..08e061d --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,73 @@ +import { Env, JWTPayload, User } from '../types'; +import { verifyJWT, createJWT, createRefreshToken } from '../utils/jwt'; +import { StorageService } from './storage'; + +export class AuthService { + private storage: StorageService; + + constructor(private env: Env) { + this.storage = new StorageService(env.VAULT); + } + + // Verify password hash (compare with stored hash) + async verifyPassword(inputHash: string, storedHash: string): Promise { + // In Bitwarden, the client sends the password hash directly + // We compare the hashes + return inputHash === storedHash; + } + + // Generate access token + async generateAccessToken(user: User): Promise { + return createJWT( + { + sub: user.id, + email: user.email, + name: user.name, + sstamp: user.securityStamp, + }, + this.env.JWT_SECRET + ); + } + + // Generate refresh token + async generateRefreshToken(userId: string): Promise { + const token = createRefreshToken(); + await this.storage.saveRefreshToken(token, userId); + return token; + } + + // Verify access token from Authorization header + async verifyAccessToken(authHeader: string | null): Promise { + if (!authHeader) return null; + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null; + } + + const payload = await verifyJWT(parts[1], this.env.JWT_SECRET); + if (!payload) return null; + + // Verify security stamp - ensures token is invalidated after password change + const user = await this.storage.getUserById(payload.sub); + if (!user) return null; + + if (payload.sstamp !== user.securityStamp) { + return null; // Token was issued before password change + } + + return payload; + } + + // Refresh access token + async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> { + const userId = await this.storage.getRefreshTokenUserId(refreshToken); + if (!userId) return null; + + const user = await this.storage.getUserById(userId); + if (!user) return null; + + const accessToken = await this.generateAccessToken(user); + return { accessToken, user }; + } +} diff --git a/src/services/ratelimit.ts b/src/services/ratelimit.ts new file mode 100644 index 0000000..54d769c --- /dev/null +++ b/src/services/ratelimit.ts @@ -0,0 +1,171 @@ +import { Env } from '../types'; + +// Rate limit configuration +const CONFIG = { + // Login attempt limits + LOGIN_MAX_ATTEMPTS: 5, // Max failed login attempts + LOGIN_LOCKOUT_MINUTES: 15, // Lockout duration after max attempts + + // API rate limits (per minute) + API_REQUESTS_PER_MINUTE: 60, // General API rate limit + API_WINDOW_SECONDS: 60, // Rate limit window +}; + +// KV key prefixes +const KEYS = { + LOGIN_ATTEMPTS: 'ratelimit:login:', + API_RATE: 'ratelimit:api:', +}; + +export class RateLimitService { + constructor(private kv: KVNamespace) {} + + /** + * Check and record login attempt + * Returns { allowed: boolean, remainingAttempts: number, retryAfterSeconds?: number } + */ + async checkLoginAttempt(email: string): Promise<{ + allowed: boolean; + remainingAttempts: number; + retryAfterSeconds?: number; + }> { + const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; + const data = await this.kv.get(key); + + if (!data) { + return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; + } + + const record: { attempts: number; lockedUntil?: number } = JSON.parse(data); + const now = Date.now(); + + // Check if currently locked out + if (record.lockedUntil && record.lockedUntil > now) { + const retryAfterSeconds = Math.ceil((record.lockedUntil - now) / 1000); + return { + allowed: false, + remainingAttempts: 0, + retryAfterSeconds, + }; + } + + // If lockout expired, reset + if (record.lockedUntil && record.lockedUntil <= now) { + await this.kv.delete(key); + return { allowed: true, remainingAttempts: CONFIG.LOGIN_MAX_ATTEMPTS }; + } + + const remainingAttempts = CONFIG.LOGIN_MAX_ATTEMPTS - record.attempts; + return { allowed: true, remainingAttempts }; + } + + /** + * Record a failed login attempt + */ + async recordFailedLogin(email: string): Promise<{ + locked: boolean; + retryAfterSeconds?: number; + }> { + const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; + const data = await this.kv.get(key); + + let record: { attempts: number; lockedUntil?: number }; + + if (data) { + record = JSON.parse(data); + record.attempts += 1; + } else { + record = { attempts: 1 }; + } + + // Check if should lock out + if (record.attempts >= CONFIG.LOGIN_MAX_ATTEMPTS) { + record.lockedUntil = Date.now() + CONFIG.LOGIN_LOCKOUT_MINUTES * 60 * 1000; + await this.kv.put(key, JSON.stringify(record), { + expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60 + 60, // Extra minute buffer + }); + return { + locked: true, + retryAfterSeconds: CONFIG.LOGIN_LOCKOUT_MINUTES * 60, + }; + } + + // Store with expiration (auto-reset after lockout period even without lockout) + await this.kv.put(key, JSON.stringify(record), { + expirationTtl: CONFIG.LOGIN_LOCKOUT_MINUTES * 60, + }); + + return { locked: false }; + } + + /** + * Clear login attempts on successful login + */ + async clearLoginAttempts(email: string): Promise { + const key = `${KEYS.LOGIN_ATTEMPTS}${email.toLowerCase()}`; + await this.kv.delete(key); + } + + /** + * Check API rate limit for a user or IP + * Returns { allowed: boolean, remaining: number, retryAfterSeconds?: number } + */ + async checkApiRateLimit(identifier: string): Promise<{ + allowed: boolean; + remaining: number; + retryAfterSeconds?: number; + }> { + const now = Math.floor(Date.now() / 1000); + const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS); + const key = `${KEYS.API_RATE}${identifier}:${windowStart}`; + + const countStr = await this.kv.get(key); + const count = countStr ? parseInt(countStr, 10) : 0; + + if (count >= CONFIG.API_REQUESTS_PER_MINUTE) { + const retryAfterSeconds = CONFIG.API_WINDOW_SECONDS - (now % CONFIG.API_WINDOW_SECONDS); + return { + allowed: false, + remaining: 0, + retryAfterSeconds, + }; + } + + return { + allowed: true, + remaining: CONFIG.API_REQUESTS_PER_MINUTE - count, + }; + } + + /** + * Increment API request count + */ + async incrementApiCount(identifier: string): Promise { + const now = Math.floor(Date.now() / 1000); + const windowStart = now - (now % CONFIG.API_WINDOW_SECONDS); + const key = `${KEYS.API_RATE}${identifier}:${windowStart}`; + + const countStr = await this.kv.get(key); + const count = countStr ? parseInt(countStr, 10) : 0; + + await this.kv.put(key, (count + 1).toString(), { + expirationTtl: CONFIG.API_WINDOW_SECONDS + 10, // Slight buffer + }); + } +} + +/** + * Get client identifier from request (IP or CF-Connecting-IP) + */ +export function getClientIdentifier(request: Request): string { + // Cloudflare provides the real client IP + const cfIp = request.headers.get('CF-Connecting-IP'); + if (cfIp) return cfIp; + + // Fallback for local development + const forwardedFor = request.headers.get('X-Forwarded-For'); + if (forwardedFor) return forwardedFor.split(',')[0].trim(); + + // Last resort + return 'unknown'; +} diff --git a/src/services/storage.ts b/src/services/storage.ts new file mode 100644 index 0000000..68541dc --- /dev/null +++ b/src/services/storage.ts @@ -0,0 +1,245 @@ +import { Env, User, Cipher, Folder, Attachment } from '../types'; + +const KEYS = { + CONFIG_REGISTERED: 'config:registered', + USER_PREFIX: 'user:', + CIPHER_PREFIX: 'cipher:', + FOLDER_PREFIX: 'folder:', + ATTACHMENT_PREFIX: 'attachment:', + CIPHERS_INDEX: 'index:ciphers', + FOLDERS_INDEX: 'index:folders', + ATTACHMENTS_INDEX: 'index:attachments', + REFRESH_TOKEN_PREFIX: 'refresh:', + REVISION_DATE_PREFIX: 'revision:', +}; + +export class StorageService { + constructor(private kv: KVNamespace) {} + + // Registration status + async isRegistered(): Promise { + const value = await this.kv.get(KEYS.CONFIG_REGISTERED); + return value === 'true'; + } + + async setRegistered(): Promise { + await this.kv.put(KEYS.CONFIG_REGISTERED, 'true'); + } + + // User operations + async getUser(email: string): Promise { + const data = await this.kv.get(`${KEYS.USER_PREFIX}${email.toLowerCase()}`); + return data ? JSON.parse(data) : null; + } + + async getUserById(id: string): Promise { + // Get user email from id mapping + const email = await this.kv.get(`userid:${id}`); + if (!email) return null; + return this.getUser(email); + } + + async saveUser(user: User): Promise { + await this.kv.put(`${KEYS.USER_PREFIX}${user.email.toLowerCase()}`, JSON.stringify(user)); + await this.kv.put(`userid:${user.id}`, user.email.toLowerCase()); + } + + // Cipher operations + async getCipher(id: string): Promise { + const data = await this.kv.get(`${KEYS.CIPHER_PREFIX}${id}`); + return data ? JSON.parse(data) : null; + } + + async saveCipher(cipher: Cipher): Promise { + await this.kv.put(`${KEYS.CIPHER_PREFIX}${cipher.id}`, JSON.stringify(cipher)); + + // Update index + const index = await this.getCipherIds(cipher.userId); + if (!index.includes(cipher.id)) { + index.push(cipher.id); + await this.kv.put(`${KEYS.CIPHERS_INDEX}:${cipher.userId}`, JSON.stringify(index)); + } + } + + async deleteCipher(id: string, userId: string): Promise { + await this.kv.delete(`${KEYS.CIPHER_PREFIX}${id}`); + + // Update index + const index = await this.getCipherIds(userId); + const newIndex = index.filter(cid => cid !== id); + await this.kv.put(`${KEYS.CIPHERS_INDEX}:${userId}`, JSON.stringify(newIndex)); + } + + async getCipherIds(userId: string): Promise { + const data = await this.kv.get(`${KEYS.CIPHERS_INDEX}:${userId}`); + return data ? JSON.parse(data) : []; + } + + async getAllCiphers(userId: string): Promise { + const ids = await this.getCipherIds(userId); + const ciphers: Cipher[] = []; + + for (const id of ids) { + const cipher = await this.getCipher(id); + if (cipher) ciphers.push(cipher); + } + + return ciphers; + } + + // Folder operations + async getFolder(id: string): Promise { + const data = await this.kv.get(`${KEYS.FOLDER_PREFIX}${id}`); + return data ? JSON.parse(data) : null; + } + + async saveFolder(folder: Folder): Promise { + await this.kv.put(`${KEYS.FOLDER_PREFIX}${folder.id}`, JSON.stringify(folder)); + + // Update index + const index = await this.getFolderIds(folder.userId); + if (!index.includes(folder.id)) { + index.push(folder.id); + await this.kv.put(`${KEYS.FOLDERS_INDEX}:${folder.userId}`, JSON.stringify(index)); + } + } + + async deleteFolder(id: string, userId: string): Promise { + await this.kv.delete(`${KEYS.FOLDER_PREFIX}${id}`); + + // Update index + const index = await this.getFolderIds(userId); + const newIndex = index.filter(fid => fid !== id); + await this.kv.put(`${KEYS.FOLDERS_INDEX}:${userId}`, JSON.stringify(newIndex)); + } + + async getFolderIds(userId: string): Promise { + const data = await this.kv.get(`${KEYS.FOLDERS_INDEX}:${userId}`); + return data ? JSON.parse(data) : []; + } + + async getAllFolders(userId: string): Promise { + const ids = await this.getFolderIds(userId); + const folders: Folder[] = []; + + for (const id of ids) { + const folder = await this.getFolder(id); + if (folder) folders.push(folder); + } + + return folders; + } + + // Refresh token operations + async saveRefreshToken(token: string, userId: string): Promise { + // Store refresh token with 30 day expiry + await this.kv.put(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`, userId, { + expirationTtl: 30 * 24 * 60 * 60, + }); + } + + async getRefreshTokenUserId(token: string): Promise { + return await this.kv.get(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`); + } + + async deleteRefreshToken(token: string): Promise { + await this.kv.delete(`${KEYS.REFRESH_TOKEN_PREFIX}${token}`); + } + + // Revision date operations (for incremental sync) + async getRevisionDate(userId: string): Promise { + const date = await this.kv.get(`${KEYS.REVISION_DATE_PREFIX}${userId}`); + return date || new Date().toISOString(); + } + + async updateRevisionDate(userId: string): Promise { + const date = new Date().toISOString(); + await this.kv.put(`${KEYS.REVISION_DATE_PREFIX}${userId}`, date); + return date; + } + + // Bulk cipher operations + async getCiphersByIds(ids: string[], userId: string): Promise { + const ciphers: Cipher[] = []; + for (const id of ids) { + const cipher = await this.getCipher(id); + if (cipher && cipher.userId === userId) { + ciphers.push(cipher); + } + } + return ciphers; + } + + async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { + const now = new Date().toISOString(); + for (const id of ids) { + const cipher = await this.getCipher(id); + if (cipher && cipher.userId === userId) { + cipher.folderId = folderId; + cipher.updatedAt = now; + await this.saveCipher(cipher); + } + } + await this.updateRevisionDate(userId); + } + + // Attachment operations + async getAttachment(id: string): Promise { + const data = await this.kv.get(`${KEYS.ATTACHMENT_PREFIX}${id}`); + return data ? JSON.parse(data) : null; + } + + async saveAttachment(attachment: Attachment): Promise { + await this.kv.put(`${KEYS.ATTACHMENT_PREFIX}${attachment.id}`, JSON.stringify(attachment)); + } + + async deleteAttachment(id: string): Promise { + await this.kv.delete(`${KEYS.ATTACHMENT_PREFIX}${id}`); + } + + async getAttachmentIdsByCipher(cipherId: string): Promise { + const data = await this.kv.get(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`); + return data ? JSON.parse(data) : []; + } + + async getAttachmentsByCipher(cipherId: string): Promise { + const ids = await this.getAttachmentIdsByCipher(cipherId); + const attachments: Attachment[] = []; + for (const id of ids) { + const attachment = await this.getAttachment(id); + if (attachment) attachments.push(attachment); + } + return attachments; + } + + async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { + const ids = await this.getAttachmentIdsByCipher(cipherId); + if (!ids.includes(attachmentId)) { + ids.push(attachmentId); + await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(ids)); + } + } + + async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { + const ids = await this.getAttachmentIdsByCipher(cipherId); + const newIds = ids.filter(id => id !== attachmentId); + await this.kv.put(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`, JSON.stringify(newIds)); + } + + async deleteAllAttachmentsByCipher(cipherId: string): Promise { + const ids = await this.getAttachmentIdsByCipher(cipherId); + for (const id of ids) { + await this.deleteAttachment(id); + } + await this.kv.delete(`${KEYS.ATTACHMENTS_INDEX}:${cipherId}`); + } + + async updateCipherRevisionDate(cipherId: string): Promise { + const cipher = await this.getCipher(cipherId); + if (cipher) { + cipher.updatedAt = new Date().toISOString(); + await this.saveCipher(cipher); + await this.updateRevisionDate(cipher.userId); + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..fad8c5b --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,242 @@ +// Environment bindings +export interface Env { + VAULT: KVNamespace; + ATTACHMENTS: R2Bucket; + JWT_SECRET: string; +} + +// Attachment model +export interface Attachment { + id: string; + cipherId: string; + fileName: string; // encrypted + size: number; + sizeName: string; + key: string | null; // encrypted attachment key +} + +// User model +export interface User { + id: string; + email: string; + name: string; + masterPasswordHash: string; + key: string; + privateKey: string | null; + publicKey: string | null; + kdfType: number; + kdfIterations: number; + kdfMemory?: number; + kdfParallelism?: number; + securityStamp: string; + createdAt: string; + updatedAt: string; +} + +// Cipher types +export enum CipherType { + Login = 1, + SecureNote = 2, + Card = 3, + Identity = 4, +} + +export interface CipherLoginUri { + uri: string | null; + uriChecksum: string | null; + match: number | null; +} + +export interface CipherLogin { + username: string | null; + password: string | null; + uris: CipherLoginUri[] | null; + totp: string | null; + autofillOnPageLoad: boolean | null; + fido2Credentials: any[] | null; +} + +export interface CipherCard { + cardholderName: string | null; + brand: string | null; + number: string | null; + expMonth: string | null; + expYear: string | null; + code: string | null; +} + +export interface CipherIdentity { + title: string | null; + firstName: string | null; + middleName: string | null; + lastName: string | null; + address1: string | null; + address2: string | null; + address3: string | null; + city: string | null; + state: string | null; + postalCode: string | null; + country: string | null; + company: string | null; + email: string | null; + phone: string | null; + ssn: string | null; + username: string | null; + passportNumber: string | null; + licenseNumber: string | null; +} + +export interface CipherSecureNote { + type: number; +} + +export interface CipherField { + name: string | null; + value: string | null; + type: number; + linkedId: number | null; +} + +export interface PasswordHistory { + password: string; + lastUsedDate: string; +} + +export interface Cipher { + id: string; + userId: string; + type: CipherType; + folderId: string | null; + name: string; + notes: string | null; + favorite: boolean; + login: CipherLogin | null; + card: CipherCard | null; + identity: CipherIdentity | null; + secureNote: CipherSecureNote | null; + fields: CipherField[] | null; + passwordHistory: PasswordHistory[] | null; + reprompt: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +// Folder model +export interface Folder { + id: string; + userId: string; + name: string; + createdAt: string; + updatedAt: string; +} + +// JWT Payload +export interface JWTPayload { + sub: string; // user id + email: string; + name: string; + email_verified: boolean; // required by mobile client + amr: string[]; // authentication methods reference - required by mobile client + sstamp: string; // security stamp - invalidates token when user changes password + iat: number; + exp: number; + iss: string; + premium: boolean; +} + +// API Response types +export interface TokenResponse { + access_token: string; + expires_in: number; + token_type: string; + refresh_token: string; + Key: string; + PrivateKey: string | null; + Kdf: number; + KdfIterations: number; + KdfMemory?: number; + KdfParallelism?: number; + ForcePasswordReset: boolean; + ResetMasterPassword: boolean; + scope: string; + unofficialServer: boolean; + UserDecryptionOptions: { + HasMasterPassword: boolean; + Object: string; + }; +} + +export interface ProfileResponse { + id: string; + name: string; + email: string; + emailVerified: boolean; + premium: boolean; + premiumFromOrganization: boolean; // required by mobile client + usesKeyConnector: boolean; // required by mobile client + masterPasswordHint: string | null; + culture: string; + twoFactorEnabled: boolean; + key: string; + privateKey: string | null; + securityStamp: string; + organizations: any[]; + providers: any[]; + providerOrganizations: any[]; + forcePasswordReset: boolean; + avatarColor: string | null; + creationDate: string; // required by mobile client + object: string; +} + +export interface CipherResponse { + id: string; + organizationId: string | null; + folderId: string | null; + type: number; + name: string; + notes: string | null; + favorite: boolean; + login: CipherLogin | null; + card: CipherCard | null; + identity: CipherIdentity | null; + secureNote: CipherSecureNote | null; + fields: CipherField[] | null; + passwordHistory: PasswordHistory[] | null; + reprompt: number; + organizationUseTotp: boolean; + creationDate: string; + revisionDate: string; + deletedDate: string | null; + edit: boolean; + viewPassword: boolean; + permissions: CipherPermissions | null; + object: string; + collectionIds: string[]; + attachments: any[] | null; +} + +export interface CipherPermissions { + delete: boolean; + restore: boolean; + edit: boolean; +} + +export interface FolderResponse { + id: string; + name: string; + revisionDate: string; + object: string; +} + +export interface SyncResponse { + profile: ProfileResponse; + folders: FolderResponse[]; + collections: any[]; + ciphers: CipherResponse[]; + domains: any; + policies: any[]; + sends: any[]; + object: string; +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..f1a399a --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,176 @@ +import { JWTPayload } from '../types'; + +// Base64 URL encode +function base64UrlEncode(data: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...data)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +// Base64 URL decode +function base64UrlDecode(str: string): Uint8Array { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + const binary = atob(str); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +// Create JWT +export async function createJWT(payload: Omit, secret: string, expiresIn: number = 7200): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + + const fullPayload: JWTPayload = { + ...payload, + email_verified: true, // required by mobile client + amr: ['Application'], // authentication methods reference - required by mobile client + iat: now, + exp: now + expiresIn, + iss: 'nodewarden', + premium: true, + }; + + const encoder = new TextEncoder(); + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload))); + + const data = `${headerB64}.${payloadB64}`; + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); + + return `${data}.${signatureB64}`; +} + +// Verify JWT +export async function verifyJWT(token: string, secret: string): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const encoder = new TextEncoder(); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const data = `${headerB64}.${payloadB64}`; + const signature = base64UrlDecode(signatureB64); + + const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data)); + if (!valid) return null; + + const payload: JWTPayload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + + return payload; + } catch { + return null; + } +} + +// Create refresh token (simple random string) +export function createRefreshToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +// File download token payload +export interface FileDownloadClaims { + cipherId: string; + attachmentId: string; + exp: number; +} + +// Create file download token (short-lived, 5 minutes) +export async function createFileDownloadToken( + cipherId: string, + attachmentId: string, + secret: string +): Promise { + const header = { alg: 'HS256', typ: 'JWT' }; + const now = Math.floor(Date.now() / 1000); + + const payload: FileDownloadClaims = { + cipherId, + attachmentId, + exp: now + 300, // 5 minutes + }; + + const encoder = new TextEncoder(); + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + + const data = `${headerB64}.${payloadB64}`; + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + const signatureB64 = base64UrlEncode(new Uint8Array(signature)); + + return `${data}.${signatureB64}`; +} + +// Verify file download token +export async function verifyFileDownloadToken( + token: string, + secret: string +): Promise { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const encoder = new TextEncoder(); + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + + const data = `${headerB64}.${payloadB64}`; + const signature = base64UrlDecode(signatureB64); + + const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data)); + if (!valid) return null; + + const payload: FileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64))); + + // Check expiration + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) return null; + + return payload; + } catch { + return null; + } +} diff --git a/src/utils/response.ts b/src/utils/response.ts new file mode 100644 index 0000000..e0fe9a9 --- /dev/null +++ b/src/utils/response.ts @@ -0,0 +1,70 @@ +// JSON response helper +export function jsonResponse(data: any, status: number = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + ...getCorsHeaders(), + ...headers, + }, + }); +} + +// Error response helper +export function errorResponse(message: string, status: number = 400): Response { + return jsonResponse( + { + error: message, + error_description: message, + ErrorModel: { + Message: message, + Object: 'error', + }, + }, + status + ); +} + +// Identity endpoint error response (for /identity/connect/token) +export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { + return jsonResponse( + { + error: error, + error_description: message, + ErrorModel: { + Message: message, + Object: 'error', + }, + }, + status + ); +} + +// CORS headers +export function getCorsHeaders(): Record { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version', + 'Access-Control-Max-Age': '86400', + }; +} + +// Handle CORS preflight +export function handleCors(): Response { + return new Response(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +// HTML response helper +export function htmlResponse(html: string, status: number = 200): Response { + return new Response(html, { + status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + ...getCorsHeaders(), + }, + }); +} diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts new file mode 100644 index 0000000..fb53804 --- /dev/null +++ b/src/utils/uuid.ts @@ -0,0 +1,4 @@ +// Generate UUID v4 +export function generateUUID(): string { + return crypto.randomUUID(); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..71c5846 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..facbafe --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,13 @@ +name = "nodewarden" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +# KV Namespace for storing vault data +[[kv_namespaces]] +binding = "VAULT" +id = "placeholder" + +# R2 Bucket for storing attachments +[[r2_buckets]] +binding = "ATTACHMENTS" +bucket_name = "nodewarden-attachments"