From c5d3052080a6cda15c206d9724ea23dae5e3df73 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 02:21:55 +0800 Subject: [PATCH 001/149] Refactor code structure for improved readability and maintainability --- .github/workflows/sync-upstream.yml | 28 + README.md | 122 +- README_EN.md | 79 ++ README_ZH.md | 114 -- package-lock.json | 66 +- package.json | 6 +- src/handlers/setup.ts | 14 +- src/handlers/setupPages.ts | 1213 ++++++++++++++++--- src/handlers/setupRegisterPage.ts | 1072 +++++++++++++---- tests/selfcheck.ts | 1694 +++++++++++++++++++++++++++ wrangler.toml | 2 +- 11 files changed, 3791 insertions(+), 619 deletions(-) create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 README_EN.md delete mode 100644 README_ZH.md create mode 100644 tests/selfcheck.ts diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000..9db7264 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,28 @@ +name: Sync upstream + +on: + schedule: + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - run: | + git remote add upstream https://github.com/shuaiplus/nodewarden.git || true + git fetch upstream + + # 强制让当前分支完全等于 upstream + git reset --hard upstream/main + + # 强制推送 + git push origin main --force diff --git a/README.md b/README.md index 152fc75..66b448c 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,47 @@ # NodeWarden -中文文档:[`README_ZH.md`](./README_ZH.md) +English:[`README_ZH.md`](./README_EN.md) -A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. +运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**。 -- Simple deploy (no VPS) -- Focused feature set -- Low maintenance - - -> Disclaimer -> - This project is **not affiliated** with Bitwarden. -> - Use at your own risk. Keep regular backups of your vault. +> **免责声明** +> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。 +> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。 --- -## Features +## 特性 +- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!** +- ✅ 数据存储基于 Cloudflare D1(SQLite) +- ✅ 完整的密码、笔记、卡片、身份信息管理 +- ✅ 文件夹和收藏功能 +- ✅ 文件附件支持(基于 R2 存储) +- ✅ 导入/导出功能 +- ✅ 网站图标获取 +- ✅ 端到端加密(服务器无法查看明文) +- ✅ 兼容常见的 Bitwarden 官方客户端 -- ✅ **Free to use. No server to manage.** -- ✅ Full support for logins, notes, cards, and identities -- ✅ Folders and favorites -- ✅ Attachments (Cloudflare R2) -- ✅ Import / export -- ✅ Website icons -- ✅ End-to-end encryption (the server can’t see plaintext) -- ✅ Compatible with common Bitwarden official clients +## 测试情况: +- ✅ Windows 客户端(v2026.1.0) +- ✅ Android App(v2026.1.0) +- ✅ 浏览器扩展(v2026.1.0) +- ⬜ macOS 客户端(未测试) +- ⬜ Linux 客户端(未测试) +--- -## Tested clients / platforms +# 快速开始 -- ✅ Windows desktop client (v2026.1.0) -- ✅ Android app (v2026.1.0) -- ✅ Browser extension (v2026.1.0) -- ⬜ macOS desktop client (not tested) -- ⬜ Linux desktop client (not tested) +### 一键部署 + +**部署步骤:** + +1. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +2. 打开部署后生成的链接,并根据网页提示完成后续操作。 --- -# Quick start +## 本地开发 -### One-click deploy - -[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) - -**Deploy steps:** - -1. Sign in with GitHub and authorize -2. Sign in to Cloudflare -3. **Important**: set `JWT_SECRET` to a strong random string (recommended: `openssl rand -hex 32`) -4. D1 database and R2 bucket will be created automatically -5. Click **Deploy** and wait for it to finish -6. After deploy, open the Cloudflare-provided Workers URL (your service URL), and register on the web page - -> ⚠️ **Reminder**: always use a strong random `JWT_SECRET`. Weak secrets may put your account at risk. - -### Configure your client - -In any Bitwarden client: - -1. Open **Settings** -2. Choose **Self-hosted environment** -3. Set **Server URL** to your Worker URL (for example: `https://your-project.your-subdomain.workers.dev`) -4. Save, then go back to the login screen - -## 🧑‍💻 Local development - -This repo is a Cloudflare Workers TypeScript project (Wrangler). +这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。 ```bash npm install @@ -72,37 +50,27 @@ npm run dev --- -## Tech stack +## 常见问题 -- **Runtime**: Cloudflare Workers -- **Data storage**: Cloudflare D1 (SQLite) -- **File storage**: Cloudflare R2 -- **Language**: TypeScript -- **Crypto**: Client-side AES-256-CBC, JWT uses HS256 +**Q: 如何备份数据?** +A: 在客户端中选择「导出密码库」,保存 JSON 文件。 + +**Q: 忘记主密码怎么办?** +A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 + +**Q: 可以多人使用吗?** +A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。 --- -## FAQ - -**Q: How do I back up my data?** -A: Use **Export vault** in your client and save the JSON file. - -**Q: What if I forget the master password?** -A: It can’t be recovered (end-to-end encryption). Keep it safe. - -**Q: Can multiple people use it?** -A: Not recommended. This project is designed for single-user usage. - ---- - -## License +## 开源协议 LGPL-3.0 License --- -## Credits +## 致谢 -- [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 +- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端 +- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考 +- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..c42220a --- /dev/null +++ b/README_EN.md @@ -0,0 +1,79 @@ +# NodeWarden +中文文档:[`README.md`](./README.md) + +A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. + +> Disclaimer +> - This project is for learning and communication only. +> - We are not responsible for any data loss. Regular vault backups are strongly recommended. +> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team. + +--- + +## Features + +- ✅ **Completely free, no server deployment needed. Thanks again to the generous sponsor!** +- ✅ Data storage on Cloudflare D1 (SQLite) +- ✅ Full support for logins, notes, cards, and identities +- ✅ Folders and favorites +- ✅ Attachments (Cloudflare R2) +- ✅ Import / export +- ✅ Website icons +- ✅ End-to-end encryption (the server can’t see plaintext) +- ✅ Compatible with common Bitwarden official clients + +## Tested clients / platforms + +- ✅ Windows desktop client (v2026.1.0) +- ✅ Android app (v2026.1.0) +- ✅ Browser extension (v2026.1.0) +- ⬜ macOS desktop client (not tested) +- ⬜ Linux desktop client (not tested) + +--- + +# Quick start + +### One-click deploy + +**Deploy steps:** + +1. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +2. Open the generated service URL and follow the on-page instructions. + + +## Local development + +This repo is a Cloudflare Workers TypeScript project (Wrangler). + +```bash +npm install +npm run dev +``` + +--- + +## FAQ + +**Q: How do I back up my data?** +A: Use **Export vault** in your client and save the JSON file. + +**Q: What if I forget the master password?** +A: It can’t be recovered (end-to-end encryption). Keep it safe. + +**Q: Can multiple people use it?** +A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden. + +--- + +## License + +LGPL-3.0 License + +--- + +## Credits + +- [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 \ No newline at end of file diff --git a/README_ZH.md b/README_ZH.md deleted file mode 100644 index 2a668f0..0000000 --- a/README_ZH.md +++ /dev/null @@ -1,114 +0,0 @@ - -# NodeWarden -English:[`README.md`](./README.md) - -一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。 - -- 部署简单(不需要 VPS) -- 功能聚焦 -- 维护成本低 - - - -> **免责声明** -> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。 -> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。 - ---- - -## 特性 -- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!** -- ✅ 完整的密码、笔记、卡片、身份信息管理 -- ✅ 文件夹和收藏功能 -- ✅ 文件附件支持(基于 R2 存储) -- ✅ 导入/导出功能 -- ✅ 网站图标获取 -- ✅ 端到端加密(服务器无法查看明文) -- ✅ 兼容常见的 Bitwarden 官方客户端 - -## 测试情况: -- ✅ Windows 客户端(v2026.1.0) -- ✅ Android App(v2026.1.0) -- ✅ 浏览器扩展(v2026.1.0) -- ⬜ macOS 客户端(未测试) -- ⬜ Linux 客户端(未测试) ---- - -# 快速开始 - -### 一键部署 - -点击下方按钮部署到 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. D1 数据库和 R2 存储桶将自动创建 -5. 点击 Deploy 等待部署完成 -6. 部署完成后,先打开 Cloudflare 给你的 Workers 链接(也就是你的服务地址),在网页上填写信息完成注册。 - -> ⚠️ **再次提醒**:请务必使用强随机的 `JWT_SECRET`,使用默认或弱密钥可能导致账户被入侵,**后果自负!** - -### 配置客户端 - -部署完成后,在任意 Bitwarden 客户端中: - -1. 打开设置(⚙️) -2. 选择「自托管环境」 -3. 服务器 URL 填入:`https://你的项目名` -4. 保存并返回登录页面 - - - ---- - -## 本地开发 - -这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。 - -```bash -npm install -npm run dev -``` - ---- - - -## 技术栈 - -- **运行环境**:Cloudflare Workers -- **数据存储**:Cloudflare D1(SQLite) -- **文件存储**:Cloudflare R2 -- **开发语言**:TypeScript -- **加密算法**:客户端 AES-256-CBC,JWT 使用 HS256 - ---- - -## 常见问题 - -**Q: 如何备份数据?** -A: 在客户端中选择「导出密码库」,保存 JSON 文件。 - -**Q: 忘记主密码怎么办?** -A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 - -**Q: 可以多人使用吗?** -A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。 - ---- - -## 开源协议 - -LGPL-3.0 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/package-lock.json b/package-lock.json index 30da3c5..ac60a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "nodewarden", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodewarden", - "version": "0.1.0", + "version": "0.2.0", "license": "LGPL-3.0", "devDependencies": { "@cloudflare/workers-types": "^4.20260131.0", + "@types/node": "^25.2.3", + "tsx": "^4.21.0", "typescript": "^5.9.3", "wrangler": "^4.61.1" } @@ -1166,6 +1168,16 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@types/node": { + "version": "25.2.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmmirror.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1264,6 +1276,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", @@ -1309,6 +1334,16 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", @@ -1388,6 +1423,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", @@ -1412,6 +1467,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmmirror.com/unenv/-/unenv-2.0.0-rc.24.tgz", diff --git a/package.json b/package.json index 752e2b4..2487f54 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "dev": "wrangler dev -c wrangler.toml", "deploymy": "wrangler deploy -c wrangler.my.toml", - "deploy": "wrangler deploy " + "deploy": "wrangler deploy", + "selfcheck": "npx tsx tests/selfcheck.ts" }, "keywords": [ "bitwarden", @@ -33,8 +34,9 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260131.0", + "@types/node": "^25.2.3", + "tsx": "^4.21.0", "typescript": "^5.9.3", "wrangler": "^4.61.1" } } - diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index b4fdd0a..60aaa1d 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -1,9 +1,10 @@ import { Env, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; -import { jsonResponse, htmlResponse, errorResponse } from '../utils/response'; -import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages'; +import { jsonResponse, errorResponse } from '../utils/response'; import { handleRegisterPage } from './setupRegisterPage'; +type JwtSecretState = 'missing' | 'default' | 'too_short'; + function getJwtSecretState(env: Env): JwtSecretState | null { const secret = (env.JWT_SECRET || '').trim(); if (!secret) return 'missing'; @@ -21,14 +22,9 @@ export async function handleSetupPage(request: Request, env: Env): Promise = { - app: 'NodeWarden', - tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。', - - // Config warning page - cfgTitle: '需要配置 JWT_SECRET', - cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。', - cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。', - cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。', - cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET', - cfgStepsAdd: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。', - cfgStepsEdit: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 找到 JWT_SECRET 并编辑。\n类型:密钥\n名称:JWT_SECRET\n值:替换为新的随机强密钥\n保存后,等待重新部署生效。', - cfgGenTitle: '随机密钥生成器', - cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。', - cfgCopy: '复制', - cfgCopied: '已复制', - cfgRefresh: '刷新', - - // Shared - by: '作者', - github: 'GitHub', - }; - - const en: Record = { - app: 'NodeWarden', - tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.', - - // Config warning page - cfgTitle: 'JWT_SECRET is required', - cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.', - cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.', - cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.', - cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare', - cfgStepsAdd: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.', - cfgStepsEdit: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → find JWT_SECRET and edit it.\nType: Secret\nName: JWT_SECRET\nValue: replace with a new strong random secret\nSave, and wait for redeploy to take effect.', - cfgGenTitle: 'Random secret generator', - cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.', - cfgCopy: 'Copy', - cfgCopied: 'Copied', - cfgRefresh: 'Refresh', - - // Shared - by: 'By', - github: 'GitHub', - }; - - return (lang === 'zh' ? zh : en)[key] ?? key; -} - -function baseStyles(): string { - // Keep consistent with existing setup page look & feel. - return ` + return ` + + + + + NodeWarden + + a { color: #175cd3; text-decoration: none; } + a:hover { text-decoration: underline; } +
@@ -214,93 +305,887 @@ export function renderJwtSecretWarningPage(request: Request, state: JwtSecretSta
NW
-

${t(lang, 'app')}

-

${t(lang, 'tag')}

+

NodeWarden

+

Minimal Bitwarden-compatible server on Cloudflare Workers.

-

${t(lang, 'cfgTitle')}

-
${t(lang, descKey)}
+
-
-
-

${t(lang, 'cfgStepsTitle')}

-

${t(lang, stepsKey) - .replace(/^类型:密钥/m, '类型:密钥') - .replace(/^名称:JWT_SECRET/m, '名称:JWT_SECRET') - .replace(/^Type: Secret/m, 'Type: Secret') - .replace(/^Name: JWT_SECRET/m, 'Name: JWT_SECRET') - }

-
- -
-

${t(lang, 'cfgGenTitle')}

-

${t(lang, 'cfgGenHint')}

-
-
-
- - +
+
+

Welcome

+

+
+

Highlights

+
    +
  • +
  • +
  • +
-
+ + +
+

JWT secret check

+

+ +
+
+

Fix steps

+
    +
    +
    +

    Random JWT_SECRET

    +

    +
    +
    +
    + + +
    +
    +
    +
    + +
    +

    Create account

    +

    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + + +

    Choose a strong password you can remember. The server cannot recover it.

    +
    + +
    + + +
    + +
    + +
    +
    +
    + + +
    + +
    +

    Sync setup

    +

    + +
    +

    Common required steps

    +
      +
    1. +
    2. +
    3. +
    +
    + +
    +
    + + +
    + +
    +

    +
      +
    1. +
    2. +
    +
    + +
    +

    +
      +
    1. +
    2. +
    3. +
    +
    +
    + +

    +
    + +
    +

    Done

    +

    + +
    +

    Server URL

    +
    +
    + +
    +

    Important

    +

    +
    + +
    +

    Hide setup page

    +

    +
    + +
    +
    +
    + +
    +
    + +
    +
    + + + + + +
    +
    + +
    +
    `; } + +export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { + const storage = new StorageService(env.DB); + const disabled = await storage.isSetupDisabled(); + if (disabled) { + return new Response(null, { status: 404 }); + } + return htmlResponse(renderRegisterPageHTML(jwtState)); +} \ No newline at end of file diff --git a/src/handlers/setupRegisterPage.ts b/src/handlers/setupRegisterPage.ts index 0312bd6..56524e4 100644 --- a/src/handlers/setupRegisterPage.ts +++ b/src/handlers/setupRegisterPage.ts @@ -2,9 +2,12 @@ import { Env } from '../types'; import { StorageService } from '../services/storage'; import { htmlResponse } from '../utils/response'; -// Registration/setup page HTML (single-file, no external assets) -// Split out from the old monolithic `setup.ts` as requested. -const registerPageHTML = ` +type JwtSecretState = 'missing' | 'default' | 'too_short'; + +function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { + const jwtStateJson = JSON.stringify(jwtState); + + return ` @@ -19,7 +22,6 @@ const registerPageHTML = ` --text: #101828; --muted: #475467; --muted2: #667085; - --accent: #111418; --danger: #b42318; --ok: #027a48; --shadow: 0 16px 44px rgba(16, 24, 40, 0.08); @@ -39,20 +41,48 @@ const registerPageHTML = ` justify-content: center; padding: 40px 24px; } - .shell { width: min(920px, 100%); } + + .shell { width: min(980px, 100%); } + .panel { padding: 40px; border: 1px solid var(--border); background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); + display: flex; + flex-direction: column; + position: relative; } + + .lang-toggle { + position: absolute; + top: 14px; + right: 14px; + height: 32px; + min-width: 62px; + padding: 0 10px; + border-radius: 10px; + border: 1px solid #d5dae1; + background: #ffffff; + color: #111418; + font-size: 13px; + font-weight: 700; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + .lang-toggle:hover { background: #f8fafc; } + .top { display: flex; gap: 14px; align-items: center; margin-bottom: 14px; } + .mark { width: 60px; height: 60px; @@ -70,16 +100,40 @@ const registerPageHTML = ` text-transform: uppercase; user-select: none; } + .title { display: flex; flex-direction: column; gap: 4px; } .title h1 { font-size: 30px; margin: 0; letter-spacing: -0.6px; } .title p { margin: 0; color: var(--muted); font-size: 15px; line-height: 1.6; } - h2 { font-size: 22px; margin: 20px 0 14px 0; letter-spacing: -0.3px; } + h2 { font-size: 22px; margin: 10px 0 14px 0; letter-spacing: -0.3px; } + h3 { font-size: 17px; margin: 0 0 10px 0; color: #1d2939; } + .lead { margin: 0; color: #344054; font-size: 16px; line-height: 1.75; } + + .step-container { + position: relative; + height: 442px; + overflow: hidden; + } + .step { + position: absolute; + inset: 0; + opacity: 0; + transform: translateX(10px); + pointer-events: none; + transition: opacity 170ms ease, transform 170ms ease; + overflow-y: auto; + padding-right: 4px; + } + .step.active { + opacity: 1; + transform: translateX(0); + pointer-events: auto; + } .message { display: none; border-radius: 12px; - padding: 14px 14px; + padding: 14px; margin: 0 0 12px 0; font-size: 15px; line-height: 1.45; @@ -99,8 +153,18 @@ const registerPageHTML = ` color: var(--ok); } + .kv { + border-radius: var(--radius2); + border: 1px solid var(--border); + background: #fafbfc; + padding: 18px; + margin-bottom: 14px; + } + .kv p { margin: 0; font-size: 15px; line-height: 1.65; color: var(--muted); } + .kv ul, .kv ol { margin: 8px 0 0 18px; padding: 0; color: var(--muted); line-height: 1.7; } + .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } - @media (max-width: 540px) { .grid { grid-template-columns: 1fr; } } + @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } } .field { display: flex; flex-direction: column; gap: 7px; } label { font-size: 14px; color: var(--muted); letter-spacing: 0.1px; } @@ -120,36 +184,98 @@ const registerPageHTML = ` border-color: #111418; box-shadow: 0 0 0 5px rgba(17, 20, 24, 0.08); } - .hint { margin: 0; color: var(--muted2); font-size: 14px; line-height: 1.6; } - .actions { margin-top: 16px; display: flex; gap: 10px; align-items: center; } - .primary { - width: 100%; - height: 52px; + .hint { margin: 0; color: var(--muted2); font-size: 14px; line-height: 1.6; } + .muted { color: var(--muted); } + + .compat-wrap { + margin-top: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: #ffffff; + padding: 10px 12px; + } + .compat-title { + margin: 0 0 8px 0; + font-size: 13px; + font-weight: 700; + color: #344054; + letter-spacing: 0.1px; + } + .compat-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .compat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid #d5dae1; + background: #f8fafc; + font-size: 13px; + color: #1d2939; + line-height: 1; + white-space: nowrap; + } + .compat-chip.ok { + border-color: #abefc6; + background: #f0fdf4; + color: #027a48; + } + .compat-chip.na { + border-color: #e4e7ec; + background: #f9fafb; + color: #667085; + } + .compat-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; + opacity: 0.9; + } + + .btn { + height: 46px; + padding: 0 16px; border-radius: 14px; - border: 1px solid #111418; + border: 1px solid #d5dae1; + background: #ffffff; + color: #111418; + font-weight: 700; + font-size: 15px; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + white-space: nowrap; + } + .btn.primary { + border-color: #111418; background: #111418; color: #ffffff; - font-weight: 700; - font-size: 16px; - letter-spacing: 0.1px; - cursor: pointer; - transition: transform 120ms ease, filter 120ms ease; } - .primary:hover { filter: brightness(1.03); } - .primary:active { transform: translateY(1px) scale(0.99); } - .primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; } + .btn:disabled { opacity: 0.55; cursor: not-allowed; } - .sideCard { display: flex; flex-direction: column; gap: 12px; } - .kv { - border-radius: var(--radius2); - border: 1px solid var(--border); - background: #fafbfc; - padding: 18px; - margin-bottom: 14px; + .mode-tabs { display: inline-flex; border: 1px solid #d5dae1; border-radius: 12px; overflow: hidden; } + .mode-tab { + border: none; + background: #fff; + color: #111418; + padding: 9px 12px; + cursor: pointer; + font-weight: 700; + font-size: 14px; } - .kv h3 { margin: 0 0 10px 0; font-size: 17px; color: #1d2939; } - .kv p { margin: 0; font-size: 15px; line-height: 1.65; color: var(--muted); } + .mode-tab.active { background: #111418; color: #fff; } + + .mode-panel { display: none; margin-top: 12px; } + .mode-panel.active { display: block; } .server { margin-top: 10px; @@ -162,11 +288,45 @@ const registerPageHTML = ` word-break: break-all; color: #111418; } + + .flow-bottom { + margin-top: 14px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + .flow-actions { display: flex; align-items: center; gap: 8px; width: 132px; } + .flow-actions .btn { width: 120px; padding: 0; } + + .dots { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 26px; + } + .dot { + width: 9px; + height: 9px; + border-radius: 999px; + background: #cfd5de; + transition: all 120ms ease; + } + .dot.active { + width: 24px; + height: 10px; + border-radius: 999px; + background: #111418; + } + a { color: #175cd3; text-decoration: none; } a:hover { text-decoration: underline; } + .footer { - margin-top: 24px; - padding-top: 18px; + margin-top: 15px; + padding-top: 10px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; @@ -175,12 +335,60 @@ const registerPageHTML = ` font-size: 14px; color: var(--muted2); } - .muted { color: var(--muted); } + + .modal-mask { + position: fixed; + inset: 0; + background: rgba(16, 24, 40, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 20px; + } + .modal-mask.show { display: flex; } + .modal { + width: min(520px, 100%); + border-radius: 16px; + border: 1px solid var(--border); + background: #ffffff; + box-shadow: 0 24px 56px rgba(16, 24, 40, 0.18); + padding: 20px; + } + .modal h3 { + margin: 0 0 8px 0; + font-size: 18px; + color: #101828; + } + .modal p { + margin: 0; + font-size: 15px; + line-height: 1.7; + color: #475467; + } + .modal-warn { + margin-top: 10px; + border: 1px solid #fecdca; + background: #fff6f5; + color: #b42318; + border-radius: 12px; + padding: 10px 12px; + font-size: 14px; + line-height: 1.6; + } + .modal-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + gap: 8px; + }
    + +
    +
    + +
    +
    + + + + +
    +
    + +
    +
    +
    + + `; +} -export async function handleRegisterPage(request: Request, env: Env): Promise { +export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { const storage = new StorageService(env.DB); const disabled = await storage.isSetupDisabled(); if (disabled) { return new Response(null, { status: 404 }); } - return htmlResponse(registerPageHTML); + return htmlResponse(renderRegisterPageHTML(jwtState)); } diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts new file mode 100644 index 0000000..9864bac --- /dev/null +++ b/tests/selfcheck.ts @@ -0,0 +1,1694 @@ +#!/usr/bin/env npx tsx +/** + * ╔══════════════════════════════════════════════════════════════╗ + * ║ NodeWarden 自查程序 — Bitwarden API 兼容性全面诊断 ║ + * ╚══════════════════════════════════════════════════════════════╝ + * + * 功能:自动验证 NodeWarden 服务端的所有 API 端点,确保兼容 + * Bitwarden 全平台客户端(Windows / Android / iOS / 浏览器 + * 插件 / Linux / macOS / CLI)。 + * + * 核心特性: + * · 内置 Bitwarden 标准 KDF(PBKDF2-SHA256),输入明文密码即可 + * · 自动注册(全新实例)或自动登录(已有用户) + * · 空保管库锁定/解锁回归测试(历史 bug 场景) + * · JWT 内部声明验证(移动端依赖) + * · 多客户端平台兼容性验证(不同 client_id、设备头) + * · CORS 深度验证(浏览器插件依赖) + * · 覆盖全部已实现端点 + 未实现端点差距分析 + * · 响应结构合规性校验(字段、格式、嵌套结构) + * · 带颜色的分组输出 + 汇总报告 + * + * 用法: + * npx tsx tests/selfcheck.ts [服务器地址] [邮箱] [明文密码] + * + * 示例: + * npx tsx tests/selfcheck.ts http://localhost:8787 test@test.com testtesttest + * + * 也可以通过环境变量传入(优先级低于命令行参数): + * NW_URL=http://localhost:8787 + * NW_EMAIL=test@test.com + * NW_PASSWORD=testtesttest + * + * 注意: + * · 运行前请确保 NodeWarden 服务器已启动(npm run dev) + * · 自查会创建测试数据(文件夹、密码项等),测试结束后会自动清理 + * · 如果是全新数据库,会自动用提供的邮箱和密码注册第一个用户 + */ + +import { pbkdf2Sync, randomBytes } from 'node:crypto'; + +// ─── 配置 ─────────────────────────────────────────────────────────────────── +// 优先取命令行参数,其次取环境变量,最后用默认值 + +const BASE = (process.argv[2] || process.env.NW_URL || 'http://localhost:8787').replace(/\/+$/, ''); +const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'test@test.com').toLowerCase(); +const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'testtesttest'); + +// ─── Bitwarden KDF ───────────────────────────────────────────────────────── +// Bitwarden 客户端在注册和登录时,不会把明文密码发给服务器。 +// 流程: +// 1. prelogin 获取 KDF 参数(kdfType, kdfIterations) +// 2. masterKey = PBKDF2-SHA256(password, salt=email, iterations, 32字节) +// 3. masterPasswordHash = Base64( PBKDF2-SHA256(masterKey, salt=password, 1次, 32字节) ) +// 4. 把 masterPasswordHash 发给服务器 +// +// 下面的函数实现了这套标准流程。 + +/** + * 计算 Bitwarden 的 masterPasswordHash + * @param password - 用户明文密码 + * @param email - 用户邮箱(小写,作为盐) + * @param kdfType - KDF 类型(0=PBKDF2, 1=Argon2id) + * @param iterations - KDF 迭代次数 + * @returns Base64 编码的 masterPasswordHash + */ +function computePasswordHash(password: string, email: string, kdfType: number, iterations: number): string { + if (kdfType !== 0) { + throw new Error(`不支持的 KDF 类型: ${kdfType}(仅支持 PBKDF2=0)`); + } + // 第一步:用邮箱作为盐,对密码做 PBKDF2 派生 → masterKey(32字节) + const masterKey = pbkdf2Sync(password, email, iterations, 32, 'sha256'); + // 第二步:用密码作为盐,对 masterKey 再做 1 次 PBKDF2 → 最终哈希 + const hash = pbkdf2Sync(masterKey, password, 1, 32, 'sha256'); + return hash.toString('base64'); +} + +/** + * 生成假的加密密钥(注册时占位用) + * 格式模拟 Bitwarden 客户端: "2.base64IV|base64Data|base64MAC" + */ +function generateFakeEncKey(): string { + const iv = randomBytes(16).toString('base64'); + const data = randomBytes(32).toString('base64'); + const mac = randomBytes(32).toString('base64'); + return `2.${iv}|${data}|${mac}`; +} + +/** + * 解码 JWT payload(不验证签名,仅用于检查声明字段) + * Bitwarden 移动端会在本地解码 JWT 检查 email_verified、amr 等字段 + */ +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + // JWT 的 base64url 编码需要转换为标准 base64 + let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + while (b64.length % 4) b64 += '='; + return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8')); + } catch { return null; } +} + +// ─── ANSI 颜色 ───────────────────────────────────────────────────────────── + +const c = { + reset : '\x1b[0m', + bold : '\x1b[1m', + dim : '\x1b[2m', + green : '\x1b[32m', + red : '\x1b[31m', + yellow: '\x1b[33m', + cyan : '\x1b[36m', + gray : '\x1b[90m', + white : '\x1b[97m', +}; + +// ─── 结果类型 ─────────────────────────────────────────────────────────────── + +type Status = 'PASS' | 'FAIL' | 'WARN' | 'SKIP'; + +interface TestResult { + group : string; + name : string; + status : Status; + detail? : string; + ms : number; +} + +// ─── 运行时状态 ───────────────────────────────────────────────────────────── + +let masterPasswordHash = ''; // 经 KDF 计算后的密码哈希 +let userEncKey = ''; // 用户加密密钥 +let accessToken = ''; // JWT 访问令牌 +let refreshToken = ''; // 刷新令牌 +let userId = ''; // 用户 ID +let testFolderId = ''; // 测试文件夹 ID +let testCipherId = ''; // 测试 Login 密码项 ID +let testCipher2Id = ''; // 测试 SecureNote 密码项 ID(将被永久删除) +let testAttachmentId = ''; // 测试附件 ID +let downloadToken = ''; // 附件下载令牌 +let isNewRegistration = false; + +const results: TestResult[] = []; + +// ─── HTTP 请求辅助 ───────────────────────────────────────────────────────── + +type FetchOpt = { + method? : string; + body? : any; + form? : Record; + auth? : boolean; + headers? : Record; +}; + +/** + * 统一 API 请求封装 + * @param path - 请求路径 + * @param opt - 选项:method、body(JSON)、form(表单)、auth(是否附加令牌)、headers + */ +async function api(path: string, opt: FetchOpt = {}): Promise<{ status: number; body: any; raw: Response }> { + const url = `${BASE}${path}`; + const headers: Record = { 'Accept': 'application/json', ...opt.headers }; + + if (opt.auth !== false && accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + let reqBody: string | undefined; + if (opt.form) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + reqBody = new URLSearchParams(opt.form).toString(); + } else if (opt.body !== undefined) { + headers['Content-Type'] = 'application/json'; + reqBody = JSON.stringify(opt.body); + } + + const resp = await fetch(url, { method: opt.method || 'GET', headers, body: reqBody, redirect: 'manual' }); + let body: any; + const text = await resp.text(); + try { body = JSON.parse(text); } catch { body = text; } + return { status: resp.status, body, raw: resp }; +} + +// ─── 测试运行器 ───────────────────────────────────────────────────────────── + +let currentGroup = ''; + +function group(name: string) { + currentGroup = name; + console.log(`\n${c.bold}${c.cyan}━━ ${name} ━━${c.reset}`); +} + +async function test(name: string, fn: () => Promise<{ ok: boolean; detail?: string; warn?: boolean }>): Promise { + const t0 = performance.now(); + let status: Status = 'PASS'; + let detail: string | undefined; + try { + const r = await fn(); + if (r.warn) { + status = 'WARN'; + } else { + status = r.ok ? 'PASS' : 'FAIL'; + } + detail = r.detail; + } catch (e: any) { + status = 'FAIL'; + detail = e.message || String(e); + } + const ms = performance.now() - t0; + results.push({ group: currentGroup, name, status, detail, ms }); + const icon = { PASS: `${c.green}✔`, FAIL: `${c.red}✘`, WARN: `${c.yellow}⚠`, SKIP: `${c.gray}○` }[status]; + const time = `${c.dim}${ms.toFixed(0)}ms${c.reset}`; + const det = detail ? ` ${c.dim}${detail}${c.reset}` : ''; + console.log(` ${icon} ${c.reset}${name} ${time}${det}`); +} + +function skip(name: string, reason: string) { + results.push({ group: currentGroup, name, status: 'SKIP', detail: reason, ms: 0 }); + console.log(` ${c.gray}○ ${name} ${c.dim}${reason}${c.reset}`); +} + +// ─── 结构验证辅助 ────────────────────────────────────────────────────────── + +/** 检查对象是否包含指定的所有键,返回缺失键列表 */ +function hasKeys(obj: any, keys: string[]): string[] { + if (!obj || typeof obj !== 'object') return ['(不是对象)']; + return keys.filter(k => !(k in obj)); +} + +/** 验证 Bitwarden 列表格式 { data: [...], object: "list" } */ +function expectList(body: any, objectName = 'list'): { ok: boolean; detail?: string } { + const missing = hasKeys(body, ['data', 'object']); + if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; + if (body.object !== objectName) return { ok: false, detail: `object="${body.object}" 期望="${objectName}"` }; + if (!Array.isArray(body.data)) return { ok: false, detail: 'data 不是数组' }; + return { ok: true }; +} + +// ─── 客户端期望的关键响应字段清单 ────────────────────────────────────────── + +// Profile:全平台客户端都会读取这些字段 +const PROFILE_KEYS = [ + 'id', 'name', 'email', 'emailVerified', 'premium', 'key', 'privateKey', + 'securityStamp', 'organizations', 'providers', 'providerOrganizations', + 'twoFactorEnabled', 'forcePasswordReset', 'culture', 'object', 'creationDate', +]; + +// Cipher:密码项响应的完整字段 +const CIPHER_KEYS = [ + 'id', 'type', 'name', 'favorite', 'reprompt', 'edit', 'viewPassword', + 'creationDate', 'revisionDate', 'object', 'collectionIds', 'organizationId', + 'permissions', 'deletedDate', +]; + +const FOLDER_KEYS = ['id', 'name', 'revisionDate', 'object']; + +// Sync:全量同步的顶级字段 +const SYNC_KEYS = [ + 'profile', 'folders', 'collections', 'ciphers', 'domains', + 'policies', 'sends', 'object', 'UserDecryptionOptions', 'userDecryption', +]; + +// Token:登录/刷新响应的必需字段 +const TOKEN_KEYS = [ + 'access_token', 'expires_in', 'token_type', 'refresh_token', + 'Key', 'PrivateKey', 'Kdf', 'KdfIterations', 'scope', 'UserDecryptionOptions', +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// 测试套件 +// ═══════════════════════════════════════════════════════════════════════════ + +// ─── 1. 服务器连通性 + Config 深度验证 ────────────────────────────────────── +// 验证服务器基础端点、Config 结构、favicon、DevTools 探针 + +async function suiteConnectivity() { + group('1 · 服务器连通性'); + + await test('GET /config 返回有效配置', async () => { + const { status, body } = await api('/config', { auth: false }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, ['version', 'environment', 'object']); + if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; + return { ok: body.object === 'config', detail: `版本 ${body.version}` }; + }); + + await test('GET /api/config(别名路径)', async () => { + const { status, body } = await api('/api/config', { auth: false }); + return { ok: status === 200 && body?.object === 'config' }; + }); + + // Config.environment 所有 URL 字段必须指向服务器自身 + // 客户端用这些 URL 构建后续请求地址 + await test('Config.environment URL 一致性', async () => { + const { body } = await api('/config', { auth: false }); + const env = body?.environment; + if (!env) return { ok: false, detail: 'environment 缺失' }; + const checks = [ + env.vault && env.vault.startsWith('http'), + env.api && env.api.includes('/api'), + env.identity && env.identity.includes('/identity'), + env.notifications && env.notifications.includes('/notifications'), + ]; + return { ok: checks.every(Boolean), detail: `vault=${env.vault}` }; + }); + + // featureStates 字段存在(客户端读取 feature flags) + await test('Config.featureStates 存在', async () => { + const { body } = await api('/config', { auth: false }); + return { ok: body?.featureStates && typeof body.featureStates === 'object' }; + }); + + await test('GET /api/version 返回版本字符串', async () => { + const { status, body } = await api('/api/version', { auth: false }); + return { ok: status === 200 && typeof body === 'string' && body.length > 0, detail: body }; + }); + + await test('GET /favicon.ico 返回 SVG 图标', async () => { + const resp = await fetch(`${BASE}/favicon.ico`); + const ct = resp.headers.get('content-type') || ''; + const text = await resp.text(); + return { ok: resp.status === 200 && ct.includes('svg') && text.includes(' { + const resp = await fetch(`${BASE}/favicon.svg`); + return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; + }); + + await test('GET /.well-known DevTools 探针端点', async () => { + const { status } = await api('/.well-known/appspecific/com.chrome.devtools.json', { auth: false }); + return { ok: status === 200 }; + }); +} + +// ─── 2. CORS 深度验证 ────────────────────────────────────────────────────── +// 浏览器插件(Chrome/Firefox/Safari/Edge)依赖 CORS 头 +// 缺少任何必需头都会导致插件请求被浏览器拦截 + +async function suiteCors() { + group('2 · CORS 深度验证(浏览器插件必需)'); + + await test('OPTIONS / 返回 204 + CORS 头', async () => { + const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); + const acao = resp.headers.get('access-control-allow-origin'); + return { ok: resp.status === 204 && acao === '*' }; + }); + + // 浏览器插件请求 /identity/connect/token 前会发 OPTIONS 预检 + await test('OPTIONS /identity/connect/token CORS 预检', async () => { + const resp = await fetch(`${BASE}/identity/connect/token`, { method: 'OPTIONS' }); + return { ok: resp.status === 204 }; + }); + + // 浏览器插件请求 /api/sync 前也会预检 + await test('OPTIONS /api/sync CORS 预检', async () => { + const resp = await fetch(`${BASE}/api/sync`, { method: 'OPTIONS' }); + return { ok: resp.status === 204 }; + }); + + // Access-Control-Allow-Headers 必须包含这些头 + // Bitwarden 客户端会发送 Device-Type、Bitwarden-Client-Name 等自定义头 + await test('CORS Allow-Headers 包含全部必需头', async () => { + const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); + const ah = (resp.headers.get('access-control-allow-headers') || '').toLowerCase(); + const required = ['authorization', 'content-type', 'accept', 'device-type', + 'bitwarden-client-name', 'bitwarden-client-version']; + const missing = required.filter(h => !ah.includes(h)); + return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : '全部包含' }; + }); + + // Allow-Methods 必须包含所有 HTTP 方法 + await test('CORS Allow-Methods 包含 GET/POST/PUT/DELETE', async () => { + const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); + const am = (resp.headers.get('access-control-allow-methods') || '').toUpperCase(); + const required = ['GET', 'POST', 'PUT', 'DELETE']; + const missing = required.filter(m => !am.includes(m)); + return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; + }); + + // 实际 JSON 响应也必须带 CORS 头(不只是 OPTIONS) + await test('JSON 响应包含 Access-Control-Allow-Origin: *', async () => { + const resp = await fetch(`${BASE}/config`); + const acao = resp.headers.get('access-control-allow-origin'); + return { ok: acao === '*' }; + }); +} + +// ─── 3. 注册与设置 ────────────────────────────────────────────────────────── + +async function suiteRegistration() { + group('3 · 注册与设置'); + + await test('GET /setup/status 返回设置状态', async () => { + const { status, body } = await api('/setup/status', { auth: false }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + return { ok: 'registered' in body && 'disabled' in body, detail: `已注册=${body.registered}` }; + }); + + // 用默认 KDF 参数计算密码哈希(注册时用默认参数) + const defaultIter = 600000; + masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, 0, defaultIter); + userEncKey = generateFakeEncKey(); + + await test('POST /api/accounts/register(单用户注册)', async () => { + const { status, body } = await api('/api/accounts/register', { + method: 'POST', auth: false, + body: { + email: EMAIL, name: EMAIL.split('@')[0], + masterPasswordHash, key: userEncKey, + kdf: 0, kdfIterations: defaultIter, kdfMemory: null, kdfParallelism: null, + keys: { publicKey: 'selfcheck-pubkey-placeholder', encryptedPrivateKey: 'selfcheck-privkey-placeholder' }, + }, + }); + if (status === 200) { isNewRegistration = true; return { ok: true, detail: '✓ 新用户创建成功' }; } + if (status === 403) { return { ok: true, detail: '已有用户注册(正常)' }; } + return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; + }); + + await test('POST /api/accounts/register 重复注册 → 403', async () => { + const { status } = await api('/api/accounts/register', { + method: 'POST', auth: false, + body: { + email: 'duplicate@test.com', masterPasswordHash: 'x', key: 'x', + kdf: 0, kdfIterations: 600000, + keys: { publicKey: 'x', encryptedPrivateKey: 'x' }, + }, + }); + return { ok: status === 403, detail: `状态码=${status}` }; + }); +} + +// ─── 4. 认证 ── 多客户端 + JWT Claims + 边界条件 ─────────────────────────── +// 覆盖所有平台的登录行为差异 + +async function suiteAuth() { + group('4 · 认证(多平台登录 + JWT 声明)'); + + // 4.1 Prelogin + await test('POST /identity/accounts/prelogin 返回 KDF 参数', async () => { + const { status, body } = await api('/identity/accounts/prelogin', { + method: 'POST', auth: false, body: { email: EMAIL }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + // 用服务器返回的真实 KDF 参数重新计算密码哈希 + masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, body.kdf, body.kdfIterations); + return { ok: true, detail: `kdf=${body.kdf} 迭代=${body.kdfIterations}` }; + }); + + // 防枚举:不存在的用户也返回默认参数 + await test('Prelogin 不存在的用户 → 返回默认参数(防枚举)', async () => { + const { status, body } = await api('/identity/accounts/prelogin', { + method: 'POST', auth: false, body: { email: 'nobody-exists@test.com' }, + }); + return { ok: status === 200 && body.kdf === 0 && body.kdfIterations === 600000 }; + }); + + // 4.2 密码登录(web client_id) + await test('密码登录 client_id=web', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { + grant_type: 'password', username: EMAIL, password: masterPasswordHash, + scope: 'api offline_access', client_id: 'web', + }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; + const missing = hasKeys(body, TOKEN_KEYS); + if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; + accessToken = body.access_token; + refreshToken = body.refresh_token; + userEncKey = body.Key; + return { ok: true, detail: `有效期=${body.expires_in}s` }; + }); + + // 4.3 不同 client_id 登录(模拟各平台客户端) + // 浏览器插件用 browser,桌面端用 desktop,移动端用 mobile,CLI 用 cli + for (const cid of ['browser', 'desktop', 'mobile', 'cli']) { + await test(`密码登录 client_id=${cid}(${ + { browser: '浏览器插件', desktop: '桌面端', mobile: '移动端', cli: 'CLI' }[cid] + })`, async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { + grant_type: 'password', username: EMAIL, password: masterPasswordHash, + scope: 'api offline_access', client_id: cid, + }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + // 更新令牌到最新的 + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: !!body.access_token && !!body.Key }; + }); + } + + // 4.4 带设备头的登录(Android/iOS 会发送 deviceType、deviceName、deviceIdentifier) + await test('带设备头登录(Android 设备参数)', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { + grant_type: 'password', username: EMAIL, password: masterPasswordHash, + scope: 'api offline_access', client_id: 'mobile', + deviceType: '0', deviceName: 'Android', deviceIdentifier: 'selfcheck-device-id', + }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: true }; + }); + + // 4.5 JSON 格式登录(部分第三方客户端用 JSON 而非 form-urlencoded) + await test('JSON 格式登录(非 form-urlencoded)', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + body: { + grant_type: 'password', username: EMAIL, password: masterPasswordHash, + scope: 'api offline_access', client_id: 'web', + }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: true }; + }); + + // 4.6 错误密码 + await test('错误密码 → 400 invalid_grant', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, + }); + return { ok: status === 400 && body?.error === 'invalid_grant' }; + }); + + // 4.7 缺少字段 + await test('缺少 grant_type → 400', async () => { + const { status } = await api('/identity/connect/token', { + method: 'POST', auth: false, form: { username: EMAIL, password: 'x' }, + }); + return { ok: status === 400 }; + }); + + // 4.8 不支持的 grant_type + await test('grant_type=client_credentials → 400', async () => { + const { status } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'client_credentials', client_id: 'x', client_secret: 'x' }, + }); + return { ok: status === 400 }; + }); + + // 4.9 JWT 内部声明验证 + // 移动端(Android/iOS)会解码 JWT 检查这些字段,缺失会导致认证失败 + await test('JWT payload 包含 email_verified=true(移动端必需)', async () => { + const payload = decodeJwtPayload(accessToken); + if (!payload) return { ok: false, detail: 'JWT 解码失败' }; + return { ok: payload.email_verified === true, detail: `email_verified=${payload.email_verified}` }; + }); + + await test('JWT payload 包含 amr=["Application"](移动端必需)', async () => { + const payload = decodeJwtPayload(accessToken); + if (!payload) return { ok: false, detail: 'JWT 解码失败' }; + return { ok: Array.isArray(payload.amr) && payload.amr.includes('Application') }; + }); + + await test('JWT payload 包含 premium=true', async () => { + const payload = decodeJwtPayload(accessToken); + return { ok: payload?.premium === true }; + }); + + await test('JWT payload 包含 sub / email / sstamp / iss', async () => { + const payload = decodeJwtPayload(accessToken); + if (!payload) return { ok: false, detail: 'JWT 解码失败' }; + const missing = ['sub', 'email', 'sstamp', 'iss'].filter(k => !(k in payload)); + return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; + }); + + // 4.10 Token 响应的 UserDecryptionOptions 深度验证 + await test('Token.UserDecryptionOptions 嵌套结构完整', async () => { + const { body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'password', username: EMAIL, password: masterPasswordHash }, + }); + accessToken = body.access_token; + refreshToken = body.refresh_token; + const udo = body?.UserDecryptionOptions; + if (!udo) return { ok: false, detail: 'UDO 缺失' }; + const mpu = udo.MasterPasswordUnlock; + if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; + const checks = [ + udo.HasMasterPassword === true, + mpu.Salt === EMAIL, + mpu.MasterKeyWrappedUserKey != null, + mpu.Kdf?.KdfType === 0, + mpu.Kdf?.Iterations === 600000 || mpu.Kdf?.Iterations > 0, + ]; + const failed = checks.filter(c => !c).length; + return { ok: failed === 0, detail: failed ? `${failed} 项检查失败` : `Salt=${mpu.Salt}` }; + }); +} + +// ─── 5. 令牌刷新完整性 ───────────────────────────────────────────────────── +// 令牌刷新是客户端后台自动行为,响应结构必须与登录一致 + +async function suiteRefresh() { + group('5 · 令牌刷新完整性'); + + if (!refreshToken) { skip('全部刷新测试', '无刷新令牌'); return; } + + // 保存旧 refresh_token 用于后续的复用测试 + const oldRefreshToken = refreshToken; + + await test('刷新令牌 → 返回全部字段', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, TOKEN_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: true, detail: '令牌已轮换' }; + }); + + // 刷新响应必须包含 UserDecryptionOptions(Android 空 vault 解锁依赖此) + await test('刷新响应包含 UserDecryptionOptions', async () => { + // 用新的 refresh_token 再刷新一次 + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + accessToken = body.access_token; + refreshToken = body.refresh_token; + const udo = body?.UserDecryptionOptions; + return { ok: !!udo && udo.HasMasterPassword === true && !!udo.MasterPasswordUnlock }; + }); + + // 刷新响应必须包含 Key 和 PrivateKey(桌面端重建加密上下文需要) + await test('刷新响应包含 Key 和 PrivateKey', async () => { + // 通过上一次测试已更新了 accessToken,直接检查最近的响应 + const { body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'refresh_token', refresh_token: refreshToken }, + }); + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: body?.Key != null && typeof body.Key === 'string' && body.Key.length > 0 }; + }); + + // 安全性:旧的 refresh_token 不可复用(令牌轮换机制) + await test('旧 refresh_token 不可复用(令牌轮换安全性)', async () => { + const { status } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'refresh_token', refresh_token: oldRefreshToken }, + }); + return { ok: status === 401, detail: `状态码=${status}` }; + }); +} + +// ─── 6. 空保管库回归测试 ─────────────────────────────────────────────────── +// 【关键场景】用户报告的 bug:刚注册、没有任何密码项,锁定后解锁报错。 +// +// 复现路径:注册 → 登录 → sync(空数据)→ 锁定(前端丢弃密钥)→ 重新登录 +// 本套件模拟这个完整流程,验证空 vault 状态下所有核心端点正常工作。 +// +// 客户端锁定/解锁的本质: +// 锁定 = 前端丢弃内存中的 masterKey 和 encKey +// 解锁 = 用密码重新派生 masterKey → 调用 /identity/connect/token → 获取 Key → 解密 +// 所以 "解锁失败" 的根因通常是 Token 响应中 Key 为空或 UDO 结构不完整。 + +async function suiteEmptyVault() { + group('6 · 空保管库回归测试(锁定/解锁 bug 场景)'); + + if (!accessToken) { skip('全部空保管库测试', '未获取到访问令牌'); return; } + + // 6.1 空 vault sync — 最核心的测试 + await test('空 vault GET /api/sync 结构完整', async () => { + const { status, body } = await api('/api/sync'); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, SYNC_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + // 即使没有数据,数组字段也必须存在且为数组(不能是 null/undefined) + const arrays = ['folders', 'collections', 'ciphers', 'policies', 'sends']; + const nullArrays = arrays.filter(k => !Array.isArray(body[k])); + if (nullArrays.length) return { ok: false, detail: `非数组字段: ${nullArrays.join(', ')}` }; + return { ok: body.object === 'sync' }; + }); + + // 6.2 空 vault 下 ciphers 列表 + await test('空 vault GET /api/ciphers → 空列表', async () => { + const { status, body } = await api('/api/ciphers'); + const r = expectList(body); + if (!r.ok) return r; + // 注意:可能上次测试残留了数据,这里只验证格式正确 + return { ok: status === 200, detail: `数量=${body.data.length}` }; + }); + + // 6.3 空 vault 下 folders 列表 + await test('空 vault GET /api/folders → 空列表', async () => { + const { status, body } = await api('/api/folders'); + const r = expectList(body); + return { ok: status === 200 && r.ok, detail: `数量=${body.data.length}` }; + }); + + // 6.4 空 vault 下 revision-date 仍然有效 + await test('空 vault revision-date 有效(>0)', async () => { + const { status, body } = await api('/api/accounts/revision-date'); + return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; + }); + + // 6.5 Sync.UserDecryptionOptions 深度验证(PascalCase — 桌面端/浏览器插件) + await test('Sync.UserDecryptionOptions 嵌套结构(桌面端/浏览器插件)', async () => { + const { body } = await api('/api/sync'); + const udo = body?.UserDecryptionOptions; + if (!udo) return { ok: false, detail: 'UDO 缺失' }; + const mpu = udo.MasterPasswordUnlock; + if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; + // Salt 必须等于用户邮箱,否则客户端 KDF 计算会出错 + if (mpu.Salt !== EMAIL) return { ok: false, detail: `Salt="${mpu.Salt}" 期望="${EMAIL}"` }; + // MasterKeyWrappedUserKey 不能为 null(这是解锁的关键数据) + if (!mpu.MasterKeyWrappedUserKey) return { ok: false, detail: 'MasterKeyWrappedUserKey 为空' }; + // Kdf 结构 + if (!mpu.Kdf) return { ok: false, detail: 'Kdf 缺失' }; + if (typeof mpu.Kdf.KdfType !== 'number') return { ok: false, detail: 'Kdf.KdfType 缺失' }; + return { ok: true, detail: `KdfType=${mpu.Kdf.KdfType} Iterations=${mpu.Kdf.Iterations}` }; + }); + + // 6.6 Sync.userDecryption 深度验证(camelCase — Android 专用) + await test('Sync.userDecryption 嵌套结构(Android 客户端)', async () => { + const { body } = await api('/api/sync'); + const ud = body?.userDecryption; + if (!ud) return { ok: false, detail: 'userDecryption 缺失' }; + const mpu = ud.masterPasswordUnlock; + if (!mpu) return { ok: false, detail: 'masterPasswordUnlock 缺失' }; + if (mpu.salt !== EMAIL) return { ok: false, detail: `salt="${mpu.salt}" 期望="${EMAIL}"` }; + if (!mpu.masterKeyWrappedUserKey) return { ok: false, detail: 'masterKeyWrappedUserKey 为空' }; + if (!mpu.kdf) return { ok: false, detail: 'kdf 缺失' }; + if (typeof mpu.kdf.kdfType !== 'number') return { ok: false, detail: 'kdf.kdfType 缺失' }; + return { ok: true }; + }); + + // 6.7 Sync.domains 结构(不能为 null) + await test('Sync.domains 结构完整', async () => { + const { body } = await api('/api/sync'); + const d = body?.domains; + if (!d) return { ok: false, detail: 'domains 缺失' }; + return { + ok: d.object === 'domains' + && Array.isArray(d.equivalentDomains) + && Array.isArray(d.globalEquivalentDomains), + }; + }); + + // 6.8 模拟锁定后解锁(本质是重新登录 → 获取完整 Token 响应) + await test('模拟解锁:重新登录获取 Key(锁定/解锁核心)', async () => { + const { status, body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { + grant_type: 'password', username: EMAIL, password: masterPasswordHash, + scope: 'api offline_access', client_id: 'web', + }, + }); + if (status !== 200) return { ok: false, detail: `登录失败 状态码=${status}` }; + // Key 必须非空且格式有效(这是解锁失败的常见根因) + if (!body.Key || typeof body.Key !== 'string' || body.Key.length < 10) { + return { ok: false, detail: `Key 无效: "${body.Key}"` }; + } + accessToken = body.access_token; + refreshToken = body.refresh_token; + return { ok: true, detail: `Key 长度=${body.Key.length}` }; + }); + + // 6.9 解锁后 sync 正常 + await test('解锁后 sync 正常', async () => { + const { status, body } = await api('/api/sync'); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const ok = body?.object === 'sync' && body?.profile?.email === EMAIL; + return { ok }; + }); + + // 6.10 Profile 的 key 字段非空(解锁时用于初始化加密上下文) + await test('Profile.key 非空(加密上下文初始化依赖)', async () => { + const { body } = await api('/api/accounts/profile'); + return { ok: body?.key != null && typeof body.key === 'string' && body.key.length > 10 }; + }); +} + +// ─── 7. 账户端点 ──────────────────────────────────────────────────────────── + +async function suiteAccounts() { + group('7 · 账户端点'); + + if (!accessToken) { skip('全部账户测试', '未获取到访问令牌'); return; } + + await test('GET /api/accounts/profile 获取用户资料', async () => { + const { status, body } = await api('/api/accounts/profile'); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, PROFILE_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + userId = body.id; + return { ok: body.object === 'profile' && body.email === EMAIL, detail: `id=${userId}` }; + }); + + // Profile 详细字段验证 + await test('Profile 字段类型正确', async () => { + const { body } = await api('/api/accounts/profile'); + const checks: [string, boolean][] = [ + ['emailVerified=true', body.emailVerified === true], + ['premium=true', body.premium === true], + ['twoFactorEnabled=bool', typeof body.twoFactorEnabled === 'boolean'], + ['forcePasswordReset=false', body.forcePasswordReset === false], + ['organizations=array', Array.isArray(body.organizations)], + ['providers=array', Array.isArray(body.providers)], + ['providerOrganizations=array', Array.isArray(body.providerOrganizations)], + ['culture=string', typeof body.culture === 'string'], + ]; + const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); + return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; + }); + + await test('PUT /api/accounts/profile 更新用户资料', async () => { + const { status, body } = await api('/api/accounts/profile', { + method: 'PUT', body: { name: 'SelfCheck Updated', masterPasswordHint: null }, + }); + return { ok: status === 200 && body?.object === 'profile' }; + }); + + await test('POST /api/accounts/keys 更新密钥', async () => { + const { status, body } = await api('/api/accounts/keys', { + method: 'POST', + body: { key: userEncKey, publicKey: 'selfcheck-pubkey', encryptedPrivateKey: 'selfcheck-privkey' }, + }); + return { ok: status === 200 && body?.object === 'profile' }; + }); + + await test('GET /api/accounts/revision-date 时间戳', async () => { + const { status, body } = await api('/api/accounts/revision-date'); + return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; + }); + + await test('POST /api/accounts/verify-password 正确密码 → 200', async () => { + const { status } = await api('/api/accounts/verify-password', { + method: 'POST', body: { masterPasswordHash }, + }); + return { ok: status === 200 }; + }); + + await test('POST /api/accounts/verify-password 错误密码 → 400', async () => { + const { status } = await api('/api/accounts/verify-password', { + method: 'POST', body: { masterPasswordHash: 'wrong-hash-value' }, + }); + return { ok: status === 400 }; + }); +} + +// ─── 8. 同步深度验证 ─────────────────────────────────────────────────────── + +async function suiteSync() { + group('8 · 同步深度验证'); + + if (!accessToken) { skip('全部同步测试', '未获取到访问令牌'); return; } + + await test('GET /api/sync 完整同步', async () => { + const { status, body } = await api('/api/sync'); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, SYNC_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + const pMissing = hasKeys(body.profile, PROFILE_KEYS); + if (pMissing.length) return { ok: false, detail: `profile 缺少: ${pMissing.join(', ')}` }; + return { ok: body.object === 'sync' }; + }); + + // Sync.profile 与独立 Profile 一致性 + await test('Sync.profile 与 GET /api/accounts/profile 一致', async () => { + const [sync, profile] = await Promise.all([api('/api/sync'), api('/api/accounts/profile')]); + const sp = sync.body?.profile; + const pp = profile.body; + return { ok: sp?.id === pp?.id && sp?.email === pp?.email && sp?.key === pp?.key }; + }); +} + +// ─── 9. 文件夹 CRUD ───────────────────────────────────────────────────────── + +async function suiteFolders() { + group('9 · 文件夹'); + + if (!accessToken) { skip('全部文件夹测试', '未获取到访问令牌'); return; } + + await test('POST /api/folders 创建', async () => { + const { status, body } = await api('/api/folders', { + method: 'POST', body: { name: '2.自查测试文件夹==' }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const missing = hasKeys(body, FOLDER_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + testFolderId = body.id; + return { ok: body.object === 'folder', detail: `id=${testFolderId}` }; + }); + + await test('GET /api/folders 列表', async () => { + const { status, body } = await api('/api/folders'); + const r = expectList(body); + if (!r.ok) return r; + return { ok: body.data.length >= 1, detail: `数量=${body.data.length}` }; + }); + + await test('GET /api/folders/:id 单个', async () => { + if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; + const { status, body } = await api(`/api/folders/${testFolderId}`); + return { ok: status === 200 && body?.id === testFolderId }; + }); + + await test('PUT /api/folders/:id 更新', async () => { + if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; + const { status, body } = await api(`/api/folders/${testFolderId}`, { + method: 'PUT', body: { name: '2.更新后文件夹==' }, + }); + return { ok: status === 200 && body?.object === 'folder' }; + }); +} + +// ─── 10. 密码项 CRUD + 边界条件 ───────────────────────────────────────────── + +async function suiteCiphers() { + group('10 · 密码项(Ciphers)'); + + if (!accessToken) { skip('全部密码项测试', '未获取到访问令牌'); return; } + + // 记录创建前的 revision-date,用于后面验证递增 + let revDateBefore = 0; + { + const { body } = await api('/api/accounts/revision-date'); + if (typeof body === 'number') revDateBefore = body; + } + + // --- 创建:四种类型 --- + + await test('POST /api/ciphers 创建 Login 类型', async () => { + const { status, body } = await api('/api/ciphers', { + method: 'POST', + body: { + type: 1, name: '2.测试登录项==', notes: '2.备注内容==', + folderId: testFolderId || null, favorite: true, reprompt: 0, + login: { + username: '2.用户名==', password: '2.密码==', + uris: [{ uri: '2.https://example.com==', match: null }], totp: null, + }, + fields: [{ name: '2.自定义字段==', value: '2.值==', type: 0, linkedId: null }], + passwordHistory: [{ password: '2.旧密码==', lastUsedDate: new Date().toISOString() }], + }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; + const missing = hasKeys(body, CIPHER_KEYS); + if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; + testCipherId = body.id; + return { ok: body.object === 'cipher' && body.type === 1, detail: `id=${testCipherId}` }; + }); + + await test('POST /api/ciphers 创建 SecureNote', async () => { + const { status, body } = await api('/api/ciphers', { + method: 'POST', body: { type: 2, name: '2.安全笔记==', secureNote: { type: 0 }, reprompt: 0 }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + testCipher2Id = body.id; + return { ok: body.type === 2, detail: `id=${testCipher2Id}` }; + }); + + await test('POST /api/ciphers 创建 Card', async () => { + const { status, body } = await api('/api/ciphers', { + method: 'POST', + body: { + type: 3, name: '2.银行卡==', reprompt: 0, + card: { cardholderName: '2.持卡人==', number: '2.卡号==', brand: '2.Visa==', + expMonth: '2.01==', expYear: '2.2030==', code: '2.123==' }, + }, + }); + return { ok: status === 200 && body?.type === 3 }; + }); + + await test('POST /api/ciphers 创建 Identity', async () => { + const { status, body } = await api('/api/ciphers', { + method: 'POST', + body: { + type: 4, name: '2.身份信息==', reprompt: 0, + identity: { firstName: '2.名==', lastName: '2.姓==', email: '2.邮箱==' }, + }, + }); + return { ok: status === 200 && body?.type === 4 }; + }); + + // 部分客户端用 { cipher: {...} } 嵌套格式 + await test('POST /api/ciphers/create 嵌套格式', async () => { + const { status, body } = await api('/api/ciphers/create', { + method: 'POST', + body: { cipher: { type: 2, name: '2.嵌套创建==', secureNote: { type: 0 }, reprompt: 0 } }, + }); + return { ok: status === 200 && body?.object === 'cipher' }; + }); + + // --- 响应字段深度验证 --- + + await test('Cipher 响应字段完整性', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { body } = await api(`/api/ciphers/${testCipherId}`); + const checks: [string, boolean][] = [ + ['organizationId=null', body.organizationId === null], + ['edit=true', body.edit === true], + ['viewPassword=true', body.viewPassword === true], + ['collectionIds=[]', Array.isArray(body.collectionIds) && body.collectionIds.length === 0], + ['permissions.delete=true', body.permissions?.delete === true], + ['permissions.restore=true', body.permissions?.restore === true], + ['deletedDate=null', body.deletedDate === null], + ]; + const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); + return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; + }); + + // --- 读取 --- + + await test('GET /api/ciphers 列表', async () => { + const { status, body } = await api('/api/ciphers'); + const r = expectList(body); + if (!r.ok) return r; + return { ok: body.data.length >= 4, detail: `数量=${body.data.length}` }; + }); + + await test('GET /api/ciphers/:id 单个', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}`); + return { ok: status === 200 && body?.id === testCipherId }; + }); + + await test('GET /api/ciphers/:id/details 详情', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}/details`); + return { ok: status === 200 && body?.id === testCipherId }; + }); + + // --- 更新 --- + + await test('PUT /api/ciphers/:id 更新', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}`, { + method: 'PUT', + body: { type: 1, name: '2.已更新==', reprompt: 0, + login: { username: '2.新用户名==', password: '2.新密码==', uris: [] } }, + }); + return { ok: status === 200 && body?.object === 'cipher' }; + }); + + // POST 方式更新(部分 Android 客户端行为) + await test('POST /api/ciphers/:id 更新(POST 别名)', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}`, { + method: 'POST', + body: { type: 1, name: '2.POST更新==', reprompt: 0, + login: { username: '2.u==', password: '2.p==', uris: [] } }, + }); + return { ok: status === 200 && body?.object === 'cipher' }; + }); + + await test('PUT /api/ciphers/:id/partial 部分更新', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}/partial`, { + method: 'PUT', body: { favorite: false, folderId: null }, + }); + return { ok: status === 200 && body?.favorite === false }; + }); + + await test('POST /api/ciphers/:id/share(单用户 stub)', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}/share`, { method: 'POST', body: {} }); + return { ok: status === 200 && body?.object === 'cipher' }; + }); + + // --- revision-date 递增验证 --- + + await test('写操作后 revision-date 递增', async () => { + const { body } = await api('/api/accounts/revision-date'); + if (typeof body !== 'number') return { ok: false, detail: '返回非数字' }; + return { ok: body >= revDateBefore, detail: `前=${revDateBefore} 后=${body}` }; + }); + + // --- 软删除、恢复、永久删除 --- + + await test('DELETE /api/ciphers/:id 软删除', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}`, { method: 'DELETE' }); + return { ok: status === 200 && body?.deletedDate != null }; + }); + + await test('PUT /api/ciphers/:id/restore 恢复', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}/restore`, { method: 'PUT' }); + return { ok: status === 200 && body?.deletedDate === null }; + }); + + await test('PUT /api/ciphers/:id/delete 软删除(别名)', async () => { + if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; + const { status, body } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'PUT' }); + return { ok: status === 200 && body?.deletedDate != null }; + }); + + // 验证 deleted 过滤功能 + await test('GET /api/ciphers 默认不含已删除项', async () => { + const { body } = await api('/api/ciphers'); + const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); + return { ok: !hasDeleted, detail: hasDeleted ? '包含已删除项' : undefined }; + }); + + await test('GET /api/ciphers?deleted=true 包含已删除项', async () => { + const { body } = await api('/api/ciphers?deleted=true'); + // 至少有一个被软删除的项(testCipher2Id) + const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); + return { ok: body?.data?.length > 0 && hasDeleted, detail: `数量=${body?.data?.length}` }; + }); + + await test('DELETE /api/ciphers/:id/delete 永久删除', async () => { + if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; + const { status } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'DELETE' }); + return { ok: status === 204 || status === 200 }; + }); + + await test('永久删除后 → 404', async () => { + if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; + const { status } = await api(`/api/ciphers/${testCipher2Id}`); + return { ok: status === 404 }; + }); + + // --- 批量操作 --- + + await test('POST /api/ciphers/move 批量移动', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status } = await api('/api/ciphers/move', { + method: 'POST', body: { ids: [testCipherId], folderId: testFolderId || null }, + }); + return { ok: status === 204 || status === 200 }; + }); + + // PUT 也应该支持(部分桌面端行为) + await test('PUT /api/ciphers/move 批量移动(PUT 别名)', async () => { + if (!testCipherId) return { ok: false, detail: '无可用密码项' }; + const { status } = await api('/api/ciphers/move', { + method: 'PUT', body: { ids: [testCipherId], folderId: null }, + }); + return { ok: status === 204 || status === 200 }; + }); + + await test('POST /api/ciphers/import 批量导入', async () => { + const { status } = await api('/api/ciphers/import', { + method: 'POST', + body: { + ciphers: [{ type: 1, name: '2.导入项==', login: { username: '2.u==', password: '2.p==' }, reprompt: 0 }], + folders: [{ name: '2.导入文件夹==' }], + folderRelationships: [{ key: 0, value: 0 }], + }, + }); + return { ok: status === 200, detail: `状态码=${status}` }; + }); +} + +// ─── 11. 附件 ─────────────────────────────────────────────────────────────── + +async function suiteAttachments() { + group('11 · 附件'); + + if (!accessToken || !testCipherId) { skip('全部附件测试', '无可用令牌或密码项'); return; } + + // v2 端点(新版客户端标准流程) + await test('POST /api/ciphers/:id/attachment/v2 创建元数据', async () => { + const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/v2`, { + method: 'POST', body: { fileName: '2.测试文件.txt==', key: '2.附件密钥==', fileSize: 42 }, + }); + if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; + testAttachmentId = body.attachmentId; + return { ok: !!testAttachmentId && body.object === 'attachment-fileUpload' && !!body.url, + detail: `id=${testAttachmentId}` }; + }); + + await test('POST /api/ciphers/:id/attachment/:aid 上传文件', async () => { + if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; + const formData = new FormData(); + formData.append('data', new Blob([new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])]), 'test.bin'); + const resp = await fetch(`${BASE}/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { + method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` }, body: formData, + }); + return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; + }); + + // 上传后验证附件出现在 cipher 的 attachments 数组中 + await test('上传后 cipher.attachments 非空(Android 依赖)', async () => { + const { body } = await api(`/api/ciphers/${testCipherId}`); + const atts = body?.attachments; + if (!Array.isArray(atts) || atts.length === 0) return { ok: false, detail: 'attachments 为空' }; + const att = atts[0]; + // Android 要求 url 非 null,size 为数字 + const checks = [ + typeof att.url === 'string' && att.url.length > 0, + typeof att.size === 'number', + typeof att.fileName === 'string', + ]; + const ok = checks.every(Boolean); + return { ok, detail: ok ? `url=${att.url} size=${att.size}` : 'url/size 格式不符' }; + }); + + // 获取下载链接 + await test('GET /api/ciphers/:id/attachment/:aid 下载链接', async () => { + if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; + const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + const ok = body.object === 'attachment' && typeof body.url === 'string' && body.url.includes('token='); + if (ok) { + const u = new URL(body.url); + downloadToken = u.searchParams.get('token') || ''; + } + return { ok }; + }); + + // 公开下载 + await test('GET /api/attachments/:cid/:aid?token= 公开下载', async () => { + if (!downloadToken) return { ok: false, detail: '无下载令牌' }; + const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=${downloadToken}`); + return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; + }); + + // 安全性:无 token 的下载应被拒绝 + await test('公开下载无 token → 401', async () => { + const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}`); + return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; + }); + + // 安全性:无效 token 的下载应被拒绝 + await test('公开下载无效 token → 401', async () => { + const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=invalid-garbage`); + return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; + }); + + // 旧版附件端点(旧版桌面客户端用这个路径) + await test('POST /api/ciphers/:id/attachment 旧版端点', async () => { + const { status } = await api(`/api/ciphers/${testCipherId}/attachment`, { + method: 'POST', body: { fileName: '2.旧版附件==', key: '2.key==', fileSize: 10 }, + }); + // 路由器已路由到同一 handler,应返回 200 + return { ok: status === 200, detail: `状态码=${status}` }; + }); + + // 删除附件 + await test('DELETE /api/ciphers/:id/attachment/:aid 删除', async () => { + if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; + const { status } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { + method: 'DELETE', + }); + return { ok: status === 200, detail: `状态码=${status}` }; + }); +} + +// ─── 12. Stub 端点 + 通知 + 子路径 ───────────────────────────────────────── +// 这些端点没有完整实现,但客户端会请求它们 +// 必须返回正确格式的空数据,否则客户端报错 + +async function suiteStubs() { + group('12 · Stub 端点(客户端兼容性)'); + + if (!accessToken) { skip('全部 Stub 测试', '未获取到访问令牌'); return; } + + const stubs: [string, string, string][] = [ + ['GET', '/api/collections', 'Collections(集合)'], + ['GET', '/api/organizations', 'Organizations(组织)'], + ['GET', '/api/sends', 'Sends(安全发送)'], + ['GET', '/api/policies', 'Policies(策略)'], + ['GET', '/api/auth-requests', 'Auth Requests(认证请求)'], + ['GET', '/api/devices', 'Devices(设备)'], + ]; + + for (const [method, path, label] of stubs) { + await test(`${method} ${path} → 空列表 stub(${label})`, async () => { + const { status, body } = await api(path, { method }); + const r = expectList(body); + return { ok: status === 200 && r.ok && body.data.length === 0, detail: r.detail ? r.detail : 'stub 占位' }; + }); + } + + // Stub 子路径测试(客户端可能请求带 ID 的子路径) + const subPaths: [string, string][] = [ + ['/api/organizations/00000000-0000-0000-0000-000000000000', '组织子路径'], + ['/api/collections/00000000-0000-0000-0000-000000000000', '集合子路径'], + ['/api/sends/00000000-0000-0000-0000-000000000000', '发送子路径'], + ['/api/policies/00000000-0000-0000-0000-000000000000', '策略子路径'], + ]; + + for (const [path, label] of subPaths) { + await test(`GET ${path}(${label})→ 不崩溃`, async () => { + const { status } = await api(path); + // 200 空列表或 404 都可以接受,关键是不能 500 + return { ok: status !== 500, detail: `状态码=${status}` }; + }); + } + + // 域名设置 + await test('GET /api/settings/domains → domains 对象', async () => { + const { status, body } = await api('/api/settings/domains'); + return { + ok: status === 200 && body?.object === 'domains' + && Array.isArray(body.equivalentDomains) + && Array.isArray(body.globalEquivalentDomains), + }; + }); + + await test('PUT /api/settings/domains 更新', async () => { + const { status, body } = await api('/api/settings/domains', { + method: 'PUT', body: { equivalentDomains: [], globalEquivalentDomains: [] }, + }); + return { ok: status === 200 && body?.object === 'domains' }; + }); + + // POST 别名(旧版客户端) + await test('POST /api/settings/domains(POST 别名)', async () => { + const { status, body } = await api('/api/settings/domains', { + method: 'POST', body: { equivalentDomains: [], globalEquivalentDomains: [] }, + }); + return { ok: status === 200 && body?.object === 'domains' }; + }); + + // 通知端点 — 桌面端和浏览器插件启动时必调 + await test('GET /notifications/hub → 200', async () => { + const resp = await fetch(`${BASE}/notifications/hub`); + return { ok: resp.status === 200 }; + }); + + // POST /notifications/hub/negotiate — SignalR 协商(桌面端/浏览器插件) + // 客户端启动时会发 POST 请求进行 SignalR 握手 + await test('POST /notifications/hub/negotiate → 200(SignalR 协商)', async () => { + const resp = await fetch(`${BASE}/notifications/hub/negotiate`, { method: 'POST' }); + return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; + }); + + // POST /notifications/hub — SignalR WebSocket 回退 + await test('POST /notifications/hub → 200(SignalR 回退)', async () => { + const resp = await fetch(`${BASE}/notifications/hub`, { method: 'POST' }); + return { ok: resp.status === 200 }; + }); + + // 带查询参数的通知路径 + await test('GET /notifications/hub?id=xxx → 200(长轮询)', async () => { + const resp = await fetch(`${BASE}/notifications/hub?id=some-connection-id`); + return { ok: resp.status === 200 }; + }); + + // 设备已知检查 + await test('GET /api/devices/knowndevice → "true"', async () => { + const resp = await fetch(`${BASE}/api/devices/knowndevice`); + const text = await resp.text(); + return { ok: resp.status === 200 && text.trim() === 'true' }; + }); + + // 带设备头的 knowndevice(iOS/Android 会附加这些头) + await test('GET /api/devices/knowndevice + Device 头', async () => { + const resp = await fetch(`${BASE}/api/devices/knowndevice`, { + headers: { + 'X-Device-Identifier': 'selfcheck-device', + 'X-Request-Email': Buffer.from(EMAIL).toString('base64'), + }, + }); + const text = await resp.text(); + return { ok: resp.status === 200 && (text.trim() === 'true' || text.trim() === 'false') }; + }); +} + +// ─── 13. 图标代理 ────────────────────────────────────────────────────────── + +async function suiteIcons() { + group('13 · 图标代理'); + + await test('GET /icons/google.com/icon.png', async () => { + const resp = await fetch(`${BASE}/icons/google.com/icon.png`); + return { ok: resp.status === 200 || resp.status === 204, detail: `状态码=${resp.status}` }; + }); +} + +// ─── 14. 认证守卫 ────────────────────────────────────────────────────────── + +async function suiteAuthGuard() { + group('14 · 认证守卫'); + + await test('GET /api/sync 无令牌 → 401', async () => { + const { status } = await api('/api/sync', { auth: false }); + return { ok: status === 401 }; + }); + + await test('GET /api/ciphers 无效令牌 → 401', async () => { + const { status } = await api('/api/ciphers', { + auth: false, headers: { 'Authorization': 'Bearer invalid.jwt.token' }, + }); + return { ok: status === 401 }; + }); + + await test('GET /api/accounts/profile 无令牌 → 401', async () => { + const { status } = await api('/api/accounts/profile', { auth: false }); + return { ok: status === 401 }; + }); + + await test('POST /api/ciphers 无令牌 → 401', async () => { + const { status } = await api('/api/ciphers', { + method: 'POST', auth: false, body: { type: 1, name: 'x', reprompt: 0 }, + }); + return { ok: status === 401 }; + }); +} + +// ─── 15. 被阻止端点完整验证 ──────────────────────────────────────────────── +// 单用户模式下禁止修改密码和删除账户 +// 路由器阻止了多个路径 × 多种 HTTP 方法 + +async function suiteBlocked() { + group('15 · 被阻止端点(单用户模式)'); + + if (!accessToken) { skip('全部阻止测试', '未获取到访问令牌'); return; } + + // POST 方法 + const blockedPaths = [ + '/api/accounts/password', + '/api/accounts/change-password', + '/api/accounts/set-password', + '/api/accounts/master-password', + '/api/accounts/delete', + '/api/accounts/delete-account', + '/api/accounts/delete-vault', + ]; + + for (const path of blockedPaths) { + await test(`POST ${path} → 501`, async () => { + const { status } = await api(path, { method: 'POST', body: {} }); + return { ok: status === 501, detail: `状态码=${status}` }; + }); + } + + // PUT 和 DELETE 也应该被阻止(路由器检查 POST|PUT|DELETE) + await test('PUT /api/accounts/password → 501', async () => { + const { status } = await api('/api/accounts/password', { method: 'PUT', body: {} }); + return { ok: status === 501, detail: `状态码=${status}` }; + }); + + await test('DELETE /api/accounts/delete → 501', async () => { + const { status } = await api('/api/accounts/delete', { method: 'DELETE' }); + return { ok: status === 501, detail: `状态码=${status}` }; + }); +} + +// ─── 16. 响应结构合规性 ──────────────────────────────────────────────────── + +async function suiteResponseSchema() { + group('16 · 响应结构合规性'); + + await test('错误响应符合 Bitwarden ErrorModel 格式', async () => { + const { body } = await api('/api/ciphers/00000000-0000-0000-0000-000000000000'); + const ok = body?.ErrorModel && body.ErrorModel.Object === 'error' && typeof body.ErrorModel.Message === 'string'; + return { ok: !!ok, detail: ok ? 'ErrorModel 正确' : `内容=${JSON.stringify(body).substring(0, 100)}` }; + }); + + await test('Identity 错误响应符合 OAuth2 格式', async () => { + const { body } = await api('/identity/connect/token', { + method: 'POST', auth: false, + form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, + }); + return { ok: typeof body?.error === 'string' && typeof body?.error_description === 'string' }; + }); + + // 404 端点也应返回 JSON(不是纯文本) + await test('404 返回 JSON ErrorModel', async () => { + const { status, body } = await api('/api/nonexistent-endpoint-12345'); + return { + ok: status === 404 && body?.ErrorModel?.Object === 'error', + detail: typeof body === 'string' ? 'HTML/纯文本' : 'JSON', + }; + }); + + // 401 端点返回 JSON + await test('401 返回 JSON ErrorModel', async () => { + const { body } = await api('/api/sync', { auth: false }); + return { ok: body?.ErrorModel?.Object === 'error' }; + }); +} + +// ─── 17. 清理 ────────────────────────────────────────────────────────────── + +async function suiteCleanup() { + group('17 · 清理与最终验证'); + + if (!accessToken) { skip('清理', '未获取到访问令牌'); return; } + + if (testFolderId) { + await test('DELETE /api/folders/:id 删除测试文件夹', async () => { + const { status } = await api(`/api/folders/${testFolderId}`, { method: 'DELETE' }); + return { ok: status === 204 || status === 200 }; + }); + + await test('文件夹删除后 → 404', async () => { + const { status } = await api(`/api/folders/${testFolderId}`); + return { ok: status === 404 }; + }); + } + + await test('最终同步一致性检查', async () => { + const { status, body } = await api('/api/sync'); + if (status !== 200) return { ok: false, detail: `状态码=${status}` }; + return { ok: true, detail: `密码项=${body.ciphers?.length ?? '?'} 文件夹=${body.folders?.length ?? '?'}` }; + }); +} + +// ─── 18. 缺失端点差距分析 ────────────────────────────────────────────────── +// 列出 Bitwarden 全客户端可能调用但 NodeWarden 尚未实现的端点 +// 200=已实现, 501=明确未实现, 404=未路由, 400=端点存在但缺参数, 其他=需关注 + +async function suiteGapAnalysis() { + group('18 · 缺失端点差距分析'); + + const gaps: [string, string, string][] = [ + ['POST', '/api/two-factor/get-authenticator', 'TOTP 两步验证'], + ['POST', '/api/two-factor/get-email', '邮件两步验证'], + ['POST', '/api/two-factor/get-duo', 'Duo 两步验证'], + ['POST', '/api/two-factor/get-webauthn', 'WebAuthn 两步验证'], + ['GET', '/api/emergency-access/trusted', '紧急访问(受信任)'], + ['GET', '/api/emergency-access/granted', '紧急访问(已授权)'], + ['POST', '/api/sends', '安全发送(创建)'], + ['POST', '/api/organizations', '组织(创建)'], + ['GET', '/api/accounts/billing', '账单信息'], + ['GET', '/api/accounts/subscription', '订阅信息'], + ['GET', '/api/accounts/tax', '税务信息'], + ['POST', '/api/accounts/api-key', 'API 密钥管理'], + ['POST', '/api/accounts/rotate-api-key', '轮换 API 密钥'], + ['POST', '/api/ciphers/purge', '清空保管库'], + ['POST', '/api/ciphers/bulk-delete', '批量删除'], + ['POST', '/api/ciphers/restore', '批量恢复'], + ['POST', '/api/folders/delete', '批量删除文件夹'], + ['GET', '/api/ciphers/organization-details', '组织密码项详情'], + ['POST', '/api/accounts/email-token', '修改邮箱'], + ['POST', '/api/accounts/verify-email', '验证邮箱'], + ['PUT', '/api/devices/identifier/x/token', '推送令牌注册'], + ['DELETE', '/api/push/token', '注销推送'], + ]; + + for (const [method, path, label] of gaps) { + await test(`${method} ${path}(${label})`, async () => { + const { status } = await api(path, { method, body: method !== 'GET' && method !== 'DELETE' ? {} : undefined }); + if (status === 200) return { ok: true, detail: '✓ 已实现' }; + if (status === 400) return { ok: true, detail: '✓ 端点存在(缺参数 400)' }; + // 未实现的端点 → 标记为 WARN(黄色),不算 PASS 也不算 FAIL + if (status === 501) return { warn: true, ok: false, detail: '未实现 (501)' }; + if (status === 404) return { warn: true, ok: false, detail: '未路由 (404)' }; + if (status === 401) return { warn: true, ok: false, detail: '需认证 (401)' }; + return { warn: true, ok: false, detail: `状态码 ${status}` }; + }); + } +} + +// ─── 19. 设置页面禁用 ────────────────────────────────────────────────────── + +async function suiteSetupDisable() { + group('19 · 设置页面禁用(单向操作)'); + + if (!isNewRegistration) { + skip('POST /setup/disable', '非全新注册,跳过此破坏性操作'); + skip('GET / 禁用后 → 404', '跳过'); + return; + } + + await test('POST /setup/disable 禁用设置页面', async () => { + const { status, body } = await api('/setup/disable', { method: 'POST', auth: false }); + return { ok: status === 200 && body?.success === true }; + }); + + await test('GET / 禁用后 → 404', async () => { + const resp = await fetch(`${BASE}/`); + return { ok: resp.status === 404 }; + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 汇总报告 +// ═══════════════════════════════════════════════════════════════════════════ + +function printSummary(): number { + const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIP: 0 }; + for (const r of results) counts[r.status]++; + const total = results.length; + const totalMs = results.reduce((s, r) => s + r.ms, 0); + + console.log(`\n${c.bold}${c.white}${'═'.repeat(60)}${c.reset}`); + console.log(`${c.bold} NodeWarden 自查报告${c.reset}`); + console.log(`${'═'.repeat(60)}`); + console.log(` ${c.green}通过 ${counts.PASS}${c.reset} │ ${c.red}失败 ${counts.FAIL}${c.reset} │ ${c.yellow}未实现 ${counts.WARN}${c.reset} │ ${c.gray}跳过 ${counts.SKIP}${c.reset} │ 总计 ${total}`); + console.log(` 耗时: ${(totalMs / 1000).toFixed(2)}s`); + console.log(`${'─'.repeat(60)}`); + + // 失败项 + const failures = results.filter(r => r.status === 'FAIL'); + if (failures.length) { + console.log(`\n${c.red}${c.bold} 失败项:${c.reset}`); + for (const f of failures) { + console.log(` ${c.red}✘ [${f.group}] ${f.name}${c.reset}`); + if (f.detail) console.log(` ${c.dim}${f.detail}${c.reset}`); + } + } + + // 未实现项 + const warns = results.filter(r => r.status === 'WARN'); + if (warns.length) { + console.log(`\n${c.yellow}${c.bold} 尚未实现的功能(${warns.length} 项):${c.reset}`); + for (const w of warns) { + console.log(` ${c.yellow}⚠ ${w.name}${c.reset} ${c.dim}${w.detail || ''}${c.reset}`); + } + } + + console.log(`\n${c.bold} 分组汇总:${c.reset}`); + const groups = new Map(); + for (const r of results) { + if (!groups.has(r.group)) groups.set(r.group, { pass: 0, fail: 0, warn: 0, total: 0 }); + const g = groups.get(r.group)!; + g.total++; + if (r.status === 'PASS') g.pass++; + if (r.status === 'FAIL') g.fail++; + if (r.status === 'WARN') g.warn++; + } + for (const [name, g] of groups) { + const icon = g.fail > 0 ? `${c.red}✘` : g.warn > 0 ? `${c.yellow}⚠` : `${c.green}✔`; + const warnStr = g.warn > 0 ? ` ${c.yellow}${g.warn} 未实现${c.reset}` : ''; + console.log(` ${icon} ${c.reset}${name} ${c.dim}(${g.pass}/${g.total})${c.reset}${warnStr}`); + } + + console.log(`\n${'═'.repeat(60)}`); + if (counts.FAIL === 0 && counts.WARN === 0) { + console.log(`${c.green}${c.bold} ✔ 全部检查通过!NodeWarden 兼容全平台 Bitwarden 客户端。${c.reset}`); + } else if (counts.FAIL === 0) { + console.log(`${c.green}${c.bold} ✔ 已实现功能全部通过!${c.reset}${c.yellow} ⚠ ${counts.WARN} 个端点尚未实现。${c.reset}`); + } else { + console.log(`${c.red}${c.bold} ✘ ${counts.FAIL} 项检查未通过,请查看上方详情。${c.reset}`); + } + console.log(`${'═'.repeat(60)}\n`); + + return counts.FAIL; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主流程 +// ═══════════════════════════════════════════════════════════════════════════ + +async function main() { + console.log(`\n${c.bold}${c.cyan}╔${'═'.repeat(58)}╗${c.reset}`); + console.log(`${c.bold}${c.cyan}║ NodeWarden 自查程序 · Bitwarden API 兼容性全面诊断 ║${c.reset}`); + console.log(`${c.bold}${c.cyan}╚${'═'.repeat(58)}╝${c.reset}`); + console.log(`${c.dim} 服务器 : ${BASE}${c.reset}`); + console.log(`${c.dim} 邮箱 : ${EMAIL}${c.reset}`); + console.log(`${c.dim} 密码 : ${'*'.repeat(PASSWORD.length)}${c.reset}`); + console.log(`${c.dim} 时间 : ${new Date().toISOString()}${c.reset}`); + + try { await fetch(`${BASE}/config`); } catch (e: any) { + console.error(`\n${c.red} ✘ 无法连接到服务器 ${BASE}${c.reset}`); + console.error(`${c.dim} 请先启动 NodeWarden: npm run dev${c.reset}`); + console.error(`${c.dim} ${e.message}${c.reset}\n`); + process.exit(1); + } + + await suiteConnectivity(); // 1. 连通性 + Config 深度 + await suiteCors(); // 2. CORS 深度验证 + await suiteRegistration(); // 3. 注册与设置 + await suiteAuth(); // 4. 认证(多平台 + JWT Claims) + await suiteRefresh(); // 5. 令牌刷新完整性 + await suiteEmptyVault(); // 6. 空保管库回归测试 + await suiteAccounts(); // 7. 账户端点 + await suiteSync(); // 8. 同步深度验证 + await suiteFolders(); // 9. 文件夹 + await suiteCiphers(); // 10. 密码项 + await suiteAttachments(); // 11. 附件 + await suiteStubs(); // 12. Stub 端点 + 通知 + await suiteIcons(); // 13. 图标代理 + await suiteAuthGuard(); // 14. 认证守卫 + await suiteBlocked(); // 15. 被阻止端点 + await suiteResponseSchema(); // 16. 响应格式合规 + await suiteCleanup(); // 17. 清理 + await suiteGapAnalysis(); // 18. 缺失端点分析 + await suiteSetupDisable(); // 19. 设置页面禁用 + + const failCount = printSummary(); + process.exit(failCount > 0 ? 1 : 0); +} + +main().catch(e => { + console.error(`\n${c.red}致命错误: ${e.message || e}${c.reset}\n`); + process.exit(2); +}); diff --git a/wrangler.toml b/wrangler.toml index a40be04..4537671 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "nodewarden-test" +name = "nodewarden" main = "src/index.ts" compatibility_date = "2024-01-01" From 6e1a8e7b5c67bcf3d53530e56df90763d891dc2c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 02:23:01 +0800 Subject: [PATCH 002/149] fix: correct link to English README in Chinese version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66b448c..68ec2da 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # NodeWarden -English:[`README_ZH.md`](./README_EN.md) +English:[`README_EN.md`](./README_EN.md) 运行在 **Cloudflare Workers** 上的 **Bitwarden 第三方服务端**。 From 326e13adf030cdfe3185a121eae62162c91282a1 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 02:25:26 +0800 Subject: [PATCH 003/149] fix: remove placeholder database_id from D1 database configuration --- wrangler.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/wrangler.toml b/wrangler.toml index 4537671..6d578b5 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -6,7 +6,6 @@ compatibility_date = "2024-01-01" [[d1_databases]] binding = "DB" database_name = "nodewarden-db" -database_id = "placeholde" # R2 Bucket for storing attachments [[r2_buckets]] From beefe2227e07fd1eeb085ac35673a1efa0c88a84 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 02:45:57 +0800 Subject: [PATCH 004/149] Refactor code structure for improved readability and maintainability --- src/handlers/setup.ts | 2 +- .../{setupRegisterPage.ts => setupPage.ts} | 4 +- src/handlers/setupPages.ts | 1191 ----------------- 3 files changed, 3 insertions(+), 1194 deletions(-) rename src/handlers/{setupRegisterPage.ts => setupPage.ts} (99%) delete mode 100644 src/handlers/setupPages.ts diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 60aaa1d..d36cdea 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -1,7 +1,7 @@ import { Env, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; -import { handleRegisterPage } from './setupRegisterPage'; +import { handleRegisterPage } from './setupPage'; type JwtSecretState = 'missing' | 'default' | 'too_short'; diff --git a/src/handlers/setupRegisterPage.ts b/src/handlers/setupPage.ts similarity index 99% rename from src/handlers/setupRegisterPage.ts rename to src/handlers/setupPage.ts index 56524e4..c336834 100644 --- a/src/handlers/setupRegisterPage.ts +++ b/src/handlers/setupPage.ts @@ -627,8 +627,8 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { s3Title: '同步策略(可跳过)', s3CommonTitle: '共同前置步骤', s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。', - s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → 你的服务 → 设置 → 构建与部署 → 源代码,先解绑当前一键部署仓库。', - s3Common3: '在同一位置重新绑定到你自己的 fork 仓库。', + s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。', + s3Common3: '在同一位置重新绑定到你自己 fork 的仓库。', manualSync: '手动同步', autoSync: '自动同步', s3ManualText: '手动同步:在 GitHub 网页端一键完成。', diff --git a/src/handlers/setupPages.ts b/src/handlers/setupPages.ts deleted file mode 100644 index 8928676..0000000 --- a/src/handlers/setupPages.ts +++ /dev/null @@ -1,1191 +0,0 @@ -import { Env } from '../types'; -import { StorageService } from '../services/storage'; -import { htmlResponse } from '../utils/response'; -import { JwtSecretState } from './setupPages'; - -function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { - const jwtStateJson = JSON.stringify(jwtState); - - return ` - - - - - NodeWarden - - - -
    - - -
    -
    - -
    -
    - - - - - -
    -
    - -
    -
    -
    - - - -`; -} - -export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { - const storage = new StorageService(env.DB); - const disabled = await storage.isSetupDisabled(); - if (disabled) { - return new Response(null, { status: 404 }); - } - return htmlResponse(renderRegisterPageHTML(jwtState)); -} \ No newline at end of file From 649f54f923f9cf2e2decc679f648c70ba35f6996 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 02:56:31 +0800 Subject: [PATCH 005/149] fix: update README to clarify deployment steps and features --- README.md | 7 ++++--- README_EN.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 68ec2da..9351196 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ English:[`README_EN.md`](./README_EN.md) - ✅ 导入/导出功能 - ✅ 网站图标获取 - ✅ 端到端加密(服务器无法查看明文) -- ✅ 兼容常见的 Bitwarden 官方客户端 +- ✅ 无感更新,零停机 ## 测试情况: - ✅ Windows 客户端(v2026.1.0) @@ -34,8 +34,9 @@ English:[`README_EN.md`](./README_EN.md) **部署步骤:** -1. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -2. 打开部署后生成的链接,并根据网页提示完成后续操作。 +1. 先在右上角fork此项目(若后续不需要更新,可不fork) +2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +3. 打开部署后生成的链接,并根据网页提示完成后续操作。 --- diff --git a/README_EN.md b/README_EN.md index c42220a..1639d99 100644 --- a/README_EN.md +++ b/README_EN.md @@ -20,7 +20,7 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. - ✅ Import / export - ✅ Website icons - ✅ End-to-end encryption (the server can’t see plaintext) -- ✅ Compatible with common Bitwarden official clients +- ✅ Seamless updates, zero downtime ## Tested clients / platforms @@ -38,8 +38,9 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. **Deploy steps:** -1. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -2. Open the generated service URL and follow the on-page instructions. +1. Fork this project (you don't need to fork it if you don't need to update it later). +2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +3. Open the generated service URL and follow the on-page instructions. ## Local development From 72ec21415b37d4b9a60db76383e49d56c1a8de09 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Feb 2026 03:10:59 +0800 Subject: [PATCH 006/149] fix: adjust layout and improve JWT_SECRET instructions on registration page --- src/handlers/setupPage.ts | 41 ++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/handlers/setupPage.ts b/src/handlers/setupPage.ts index c336834..e74176d 100644 --- a/src/handlers/setupPage.ts +++ b/src/handlers/setupPage.ts @@ -42,7 +42,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { padding: 40px 24px; } - .shell { width: min(980px, 100%); } + .shell { width: min(900px, 100%); } .panel { padding: 40px; @@ -426,22 +426,21 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string {

    JWT secret check

    -

    -
    +

    Fix steps

    -
    -
    + +

    Random JWT_SECRET

    -

    +
    @@ -606,7 +605,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { s1CompatOther: '其他:未测试', s2Title: '环境检测:JWT_SECRET', - s2DescGood: 'JWT_SECRET 检测通过。若你当前不是使用高强度随机密钥,建议改用右侧生成器生成后再保存。', + s2DescGood: 'JWT_SECRET 检测通过。', s2DescMissing: '检测到 JWT_SECRET 未配置,先添加后再继续。', s2DescDefault: '检测到 JWT_SECRET 使用默认值,先更换后再继续。', s2DescShort: '检测到 JWT_SECRET 长度小于 32,先更换后再继续。', @@ -618,8 +617,8 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { s2FixStep2Replace: '打开 设置 → 变量和机密,找到 JWT_SECRET 并编辑为新值。', s2FixStep3: '保存并等待服务重新部署完成。', s2FixStep4: '设置完后回到本页,刷新页面继续。', + s2FixStep5: '如需新密钥,可在本卡片下方生成并复制后再粘贴到 JWT_SECRET。', s2GenTitle: '随机密钥生成器', - s2GenDesc: '建议至少 32 位,推荐 64 位。复制后粘贴到 JWT_SECRET。', refresh: '刷新', copy: '复制', copied: '已复制', @@ -695,7 +694,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { s1CompatOther: 'Others: not tested', s2Title: 'Environment check: JWT_SECRET', - s2DescGood: 'JWT_SECRET check passed. If you are not using a strong random secret yet, we still recommend replacing it with one from the generator on the right.', + s2DescGood: 'JWT_SECRET check passed.', s2DescMissing: 'JWT_SECRET is missing. Add it before continuing.', s2DescDefault: 'JWT_SECRET is default/sample. Replace it before continuing.', s2DescShort: 'JWT_SECRET is shorter than 32 chars. Replace it before continuing.', @@ -707,8 +706,8 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { s2FixStep2Replace: 'Go to Settings → Variables and Secrets, edit JWT_SECRET with a new value.', s2FixStep3: 'Save and wait for redeploy to complete.', s2FixStep4: 'After setting it, come back and refresh this page to continue.', + s2FixStep5: 'If needed, generate a new secret in the section below, then copy and paste it into JWT_SECRET.', s2GenTitle: 'Random secret generator', - s2GenDesc: 'Use 32+ chars (64 recommended). Copy and paste into JWT_SECRET.', refresh: 'Refresh', copy: 'Copy', copied: 'Copied', @@ -778,19 +777,18 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { if (!el) return; if (!JWT_STATE) { el.innerHTML = isChinese() - ? '
    当前状态:已通过
    1. 你可以继续下一步,不影响使用。
    2. 如果当前密钥不是强随机值,建议复制右侧生成器的 64 位密钥。
    3. 到 Cloudflare 控制台 → Workers 和 Pages → 你的服务 → 设置 → 变量和机密,更新 JWT_SECRET。
    4. 保存并等待重新部署完成,然后刷新本页确认。
    ' - : '
    Current status: passed
    1. You can continue directly.
    2. If your current secret is not a strong random one, copy a 64-char secret from the generator.
    3. Go to Cloudflare Dashboard → Workers & Pages → your service → Settings → Variables and Secrets, then update JWT_SECRET.
    4. Save, wait for redeploy, and refresh this page to confirm.
    '; + ? '
    1. 你可以继续下一步,不影响使用。
    2. 如果当前密钥不是强随机值,建议复制下方生成器的 64 位密钥。
    3. 到 Cloudflare 控制台 → Workers 和 Pages → 你的服务 → 设置 → 变量和机密,更新 JWT_SECRET。
    4. 保存并等待重新部署完成,然后刷新本页确认。
    ' + : '
    1. You can continue directly.
    2. If your current secret is not a strong random one, copy a 64-char secret from the generator below.
    3. Go to Cloudflare Dashboard → Workers & Pages → your service → Settings → Variables and Secrets, then update JWT_SECRET.
    4. Save, wait for redeploy, and refresh this page to confirm.
    '; return; } const isAdd = JWT_STATE === 'missing'; - const title = isAdd ? t('s2FixAddTitle') : t('s2FixReplaceTitle'); const step2 = isAdd ? t('s2FixStep2Add') : t('s2FixStep2Replace'); - el.innerHTML = '
    ' + title + '
    ' - + '
      ' + el.innerHTML = '
        ' + '
      1. ' + t('s2FixStep1') + '
      2. ' + '
      3. ' + step2 + '
      4. ' + '
      5. ' + t('s2FixStep3') + '
      6. ' + '
      7. ' + t('s2FixStep4') + '
      8. ' + + '
      9. ' + t('s2FixStep5') + '
      10. ' + '
      '; } @@ -817,7 +815,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { setText('t_s2_fix_title', t('s2FixTitle')); renderJwtFixSteps(); setText('t_s2_gen_title', t('s2GenTitle')); - setText('t_s2_gen_desc', t('s2GenDesc')); setText('refreshSecretBtn', t('refresh')); setText('copySecretBtn', t('copy')); @@ -861,12 +858,12 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { setText('nextBtn', t('next')); setText('langToggle', isChinese() ? 'EN' : '中文'); - const desc = document.getElementById('t_s2_desc'); - if (desc) { - if (!JWT_STATE) desc.textContent = t('s2DescGood'); - else if (JWT_STATE === 'missing') desc.textContent = t('s2DescMissing'); - else if (JWT_STATE === 'default') desc.textContent = t('s2DescDefault'); - else desc.textContent = t('s2DescShort'); + const title = document.getElementById('t_s2_title'); + if (title) { + if (!JWT_STATE) title.textContent = t('s2DescGood'); + else if (JWT_STATE === 'missing') title.textContent = t('s2DescMissing'); + else if (JWT_STATE === 'default') title.textContent = t('s2DescDefault'); + else title.textContent = t('s2DescShort'); } } From 1d1cbd2c8ed3c09e93893cd84f847079c8d0dde0 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 17 Feb 2026 22:20:01 +0800 Subject: [PATCH 007/149] fix: enhance cipher handling to support unknown fields and improve database binding --- src/handlers/ciphers.ts | 71 +++++++----------- src/router.ts | 20 ++++- src/services/storage.ts | 158 ++++++++++++++++++++-------------------- src/types/index.ts | 4 + 4 files changed, 131 insertions(+), 122 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 79fce4f..d38f50e 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -18,28 +18,24 @@ export function formatAttachments(attachments: Attachment[]): any[] | null { })); } -// Convert internal cipher to API response format +// Convert internal cipher to API response format. +// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones), +// then overlays server-computed fields. This ensures new Bitwarden client fields +// survive a round-trip without code changes. export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { + // Strip internal-only fields that must not appear in the API response + const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; + return { - id: cipher.id, - organizationId: null, - folderId: cipher.folderId, + // Pass through ALL stored cipher fields (known + unknown) + ...passthrough, + // Server-computed / enforced fields (always override) type: Number(cipher.type) || 1, - name: cipher.name, - notes: cipher.notes, - favorite: cipher.favorite, - login: cipher.login, - card: cipher.card, - identity: cipher.identity, - secureNote: cipher.secureNote, - sshKey: cipher.sshKey, - fields: cipher.fields, - passwordHistory: cipher.passwordHistory, - reprompt: cipher.reprompt, + organizationId: null, organizationUseTotp: false, - creationDate: cipher.createdAt, - revisionDate: cipher.updatedAt, - deletedDate: cipher.deletedAt, + creationDate: createdAt, + revisionDate: updatedAt, + deletedDate: deletedAt, archivedDate: null, edit: true, viewPassword: true, @@ -50,7 +46,6 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []) object: 'cipher', collectionIds: [], attachments: formatAttachments(attachments), - key: cipher.key, encryptedFor: null, }; } @@ -113,23 +108,16 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str const cipherData = body.Cipher || body.cipher || body; const now = new Date().toISOString(); + // Opaque passthrough: spread ALL client fields to preserve unknown/future ones, + // then override only server-controlled fields. const cipher: Cipher = { + ...cipherData, + // Server-controlled fields (always override client values) id: generateUUID(), userId: userId, type: Number(cipherData.type) || 1, - folderId: cipherData.folderId || null, - name: cipherData.name || null, - notes: cipherData.notes || null, - favorite: cipherData.favorite || false, - login: cipherData.login || null, - card: cipherData.card || null, - identity: cipherData.identity || null, - secureNote: cipherData.secureNote || null, - sshKey: cipherData.sshKey || null, - fields: cipherData.fields || null, - passwordHistory: cipherData.passwordHistory || null, + favorite: !!cipherData.favorite, reprompt: cipherData.reprompt || 0, - key: cipherData.key || null, createdAt: now, updatedAt: now, deletedAt: null, @@ -161,23 +149,20 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str // Android client sends PascalCase "Cipher" for organization ciphers const cipherData = body.Cipher || body.cipher || body; + // Opaque passthrough: merge existing stored data with ALL incoming client fields. + // Unknown/future fields from the client are preserved; server-controlled fields are protected. const cipher: Cipher = { - ...existingCipher, + ...existingCipher, // start with all existing stored data (including unknowns) + ...cipherData, // overlay all client data (including new/unknown fields) + // Server-controlled fields (never from client) + id: existingCipher.id, + userId: existingCipher.userId, type: Number(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, - sshKey: cipherData.sshKey !== undefined ? cipherData.sshKey : existingCipher.sshKey, - fields: cipherData.fields !== undefined ? cipherData.fields : existingCipher.fields, - passwordHistory: cipherData.passwordHistory !== undefined ? cipherData.passwordHistory : existingCipher.passwordHistory, reprompt: cipherData.reprompt ?? existingCipher.reprompt, - key: cipherData.key !== undefined ? cipherData.key : existingCipher.key, + createdAt: existingCipher.createdAt, updatedAt: new Date().toISOString(), + deletedAt: existingCipher.deletedAt, }; await storage.saveCipher(cipher); diff --git a/src/router.ts b/src/router.ts index b3fe3b9..659aa02 100644 --- a/src/router.ts +++ b/src/router.ts @@ -180,6 +180,18 @@ export async function handleRequest(request: Request, env: Env): Promise= 2024.2.0 + // (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER) + // (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION) + // - MasterPasswordUnlockData (mobile): >= 2025.8.0 + // (documented in Vaultwarden source comments) + // There is NO global minimum version that blocks all client functionality. + // Keep this aligned with Vaultwarden's reported version to maintain compatibility. + // When Vaultwarden bumps their version, update this value accordingly. + // Vaultwarden source: src/api/core/mod.rs → fn config() version: '2025.12.0', gitHash: 'nodewarden', server: null, @@ -190,8 +202,14 @@ export async function handleRequest(request: Request, env: Env): Promise v === undefined ? null : v)); + } + private async sha256Hex(input: string): Promise { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', bytes); @@ -229,58 +239,54 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async saveUser(user: User): Promise { const email = user.email.toLowerCase(); - await this.db - .prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET ' + - 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' - ) - .bind( - user.id, - email, - user.name, - user.masterPasswordHash, - user.key, - user.privateKey, - user.publicKey, - user.kdfType, - user.kdfIterations, - user.kdfMemory ?? null, - user.kdfParallelism ?? null, - user.securityStamp, - user.createdAt, - user.updatedAt - ) - .run(); + const stmt = this.db.prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' + ); + await this.safeBind(stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.createdAt, + user.updatedAt + ).run(); } async createFirstUser(user: User): Promise { const email = user.email.toLowerCase(); - const result = await this.db - .prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + - 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' - ) - .bind( - user.id, - email, - user.name, - user.masterPasswordHash, - user.key, - user.privateKey, - user.publicKey, - user.kdfType, - user.kdfIterations, - user.kdfMemory ?? null, - user.kdfParallelism ?? null, - user.securityStamp, - user.createdAt, - user.updatedAt - ) - .run(); + const stmt = this.db.prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' + ); + const result = await this.safeBind(stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.createdAt, + user.updatedAt + ).run(); return (result.meta.changes ?? 0) > 0; } @@ -294,29 +300,27 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async saveCipher(cipher: Cipher): Promise { const data = JSON.stringify(cipher); - await this.db - .prepare( - 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET ' + - 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' - ) - .bind( - cipher.id, - cipher.userId, - Number(cipher.type) || 1, - cipher.folderId, - cipher.name, - cipher.notes, - cipher.favorite ? 1 : 0, - data, - cipher.reprompt ?? 0, - cipher.key, - cipher.createdAt, - cipher.updatedAt, - cipher.deletedAt - ) - .run(); + const stmt = this.db.prepare( + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' + ); + await this.safeBind(stmt, + cipher.id, + cipher.userId, + Number(cipher.type) || 1, + cipher.folderId, + cipher.name, + cipher.notes, + cipher.favorite ? 1 : 0, + data, + cipher.reprompt ?? 0, + cipher.key, + cipher.createdAt, + cipher.updatedAt, + cipher.deletedAt + ).run(); } async deleteCipher(id: string, userId: string): Promise { @@ -424,13 +428,11 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); } async saveAttachment(attachment: Attachment): Promise { - await this.db - .prepare( - 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' - ) - .bind(attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key) - .run(); + const stmt = this.db.prepare( + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' + ); + await this.safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); } async deleteAttachment(id: string): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index 9a7ee4b..1e5026d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -134,6 +134,8 @@ export interface Cipher { createdAt: string; updatedAt: string; deletedAt: string | null; + /** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */ + [key: string]: any; } // Folder model @@ -254,6 +256,8 @@ export interface CipherResponse { attachments: any[] | null; key: string | null; encryptedFor: string | null; + /** Allow unknown fields to pass through to clients transparently. */ + [key: string]: any; } export interface CipherPermissions { From 73db6c518b20318e043e854af3afd1f70f517519 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 17 Feb 2026 22:47:15 +0800 Subject: [PATCH 008/149] fix: track and clean up test-created cipher and folder IDs to prevent undecryptable items --- tests/selfcheck.ts | 70 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts index 9864bac..c4abca1 100644 --- a/tests/selfcheck.ts +++ b/tests/selfcheck.ts @@ -140,6 +140,11 @@ let testAttachmentId = ''; // 测试附件 ID let downloadToken = ''; // 附件下载令牌 let isNewRegistration = false; +// Track ALL test-created cipher and folder IDs so cleanup can permanently delete them. +// This prevents leftover undecryptable "[error: cannot decrypt]" items in the vault. +const allCreatedCipherIds: string[] = []; +const allCreatedFolderIds: string[] = []; + const results: TestResult[] = []; // ─── HTTP 请求辅助 ───────────────────────────────────────────────────────── @@ -957,6 +962,7 @@ async function suiteCiphers() { const missing = hasKeys(body, CIPHER_KEYS); if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; testCipherId = body.id; + allCreatedCipherIds.push(body.id); return { ok: body.object === 'cipher' && body.type === 1, detail: `id=${testCipherId}` }; }); @@ -966,6 +972,7 @@ async function suiteCiphers() { }); if (status !== 200) return { ok: false, detail: `状态码=${status}` }; testCipher2Id = body.id; + allCreatedCipherIds.push(body.id); return { ok: body.type === 2, detail: `id=${testCipher2Id}` }; }); @@ -978,6 +985,7 @@ async function suiteCiphers() { expMonth: '2.01==', expYear: '2.2030==', code: '2.123==' }, }, }); + if (body?.id) allCreatedCipherIds.push(body.id); return { ok: status === 200 && body?.type === 3 }; }); @@ -989,6 +997,7 @@ async function suiteCiphers() { identity: { firstName: '2.名==', lastName: '2.姓==', email: '2.邮箱==' }, }, }); + if (body?.id) allCreatedCipherIds.push(body.id); return { ok: status === 200 && body?.type === 4 }; }); @@ -998,6 +1007,7 @@ async function suiteCiphers() { method: 'POST', body: { cipher: { type: 2, name: '2.嵌套创建==', secureNote: { type: 0 }, reprompt: 0 } }, }); + if (body?.id) allCreatedCipherIds.push(body.id); return { ok: status === 200 && body?.object === 'cipher' }; }); @@ -1151,7 +1161,7 @@ async function suiteCiphers() { }); await test('POST /api/ciphers/import 批量导入', async () => { - const { status } = await api('/api/ciphers/import', { + const { status, body } = await api('/api/ciphers/import', { method: 'POST', body: { ciphers: [{ type: 1, name: '2.导入项==', login: { username: '2.u==', password: '2.p==' }, reprompt: 0 }], @@ -1159,6 +1169,9 @@ async function suiteCiphers() { folderRelationships: [{ key: 0, value: 0 }], }, }); + // Track imported ciphers/folders for cleanup + if (body?.ciphers) for (const c of body.ciphers) { if (c?.id) allCreatedCipherIds.push(c.id); } + if (body?.folders) for (const f of body.folders) { if (f?.id) allCreatedFolderIds.push(f.id); } return { ok: status === 200, detail: `状态码=${status}` }; }); } @@ -1492,6 +1505,48 @@ async function suiteCleanup() { if (!accessToken) { skip('清理', '未获取到访问令牌'); return; } + // Permanently delete ALL test-created ciphers to avoid "[error: cannot decrypt]" leftovers. + // Collect any remaining ciphers from a sync in case some IDs were not tracked (e.g. import). + try { + const { body } = await api('/api/sync'); + if (body?.ciphers) { + for (const c of body.ciphers) { + if (c?.id && !allCreatedCipherIds.includes(c.id)) { + // Check if this cipher has a fake encrypted name (our test marker) + const n = c.name || ''; + if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { + allCreatedCipherIds.push(c.id); + } + } + } + // Also find orphan test folders from import + for (const f of (body.folders || [])) { + if (f?.id && f.id !== testFolderId && !allCreatedFolderIds.includes(f.id)) { + const n = f.name || ''; + if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { + allCreatedFolderIds.push(f.id); + } + } + } + } + } catch { /* best effort */ } + + // Delete all tracked ciphers + const cipherIds = [...new Set(allCreatedCipherIds)]; + if (cipherIds.length > 0) { + await test(`永久删除所有测试密码项 (${cipherIds.length} 个)`, async () => { + let deleted = 0; + for (const id of cipherIds) { + // Soft-delete first (required for permanent delete if not already soft-deleted) + await api(`/api/ciphers/${id}`, { method: 'DELETE' }).catch(() => {}); + const { status } = await api(`/api/ciphers/${id}/delete`, { method: 'DELETE' }); + if (status === 204 || status === 200) deleted++; + } + return { ok: deleted > 0, detail: `已删除 ${deleted}/${cipherIds.length}` }; + }); + } + + // Delete test folder if (testFolderId) { await test('DELETE /api/folders/:id 删除测试文件夹', async () => { const { status } = await api(`/api/folders/${testFolderId}`, { method: 'DELETE' }); @@ -1504,6 +1559,19 @@ async function suiteCleanup() { }); } + // Delete any extra test folders (from import etc.) + const extraFolderIds = [...new Set(allCreatedFolderIds)]; + if (extraFolderIds.length > 0) { + await test(`删除导入的测试文件夹 (${extraFolderIds.length} 个)`, async () => { + let deleted = 0; + for (const id of extraFolderIds) { + const { status } = await api(`/api/folders/${id}`, { method: 'DELETE' }); + if (status === 204 || status === 200) deleted++; + } + return { ok: true, detail: `已删除 ${deleted}/${extraFolderIds.length}` }; + }); + } + await test('最终同步一致性检查', async () => { const { status, body } = await api('/api/sync'); if (status !== 200) return { ok: false, detail: `状态码=${status}` }; From e1f1c6f865647c25368bacad114536709d5c8c96 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Feb 2026 03:06:50 +0800 Subject: [PATCH 009/149] fix: enhance attachment handling and folder deletion logic; improve error responses and rate limiting --- src/handlers/attachments.ts | 6 +++- src/handlers/folders.ts | 1 + src/handlers/identity.ts | 21 ++++++----- src/handlers/setupPage.ts | 6 ---- src/handlers/sync.ts | 19 ++++++---- src/router.ts | 71 ++++++++++++++++++++++++++++++++++--- src/services/ratelimit.ts | 40 +++++++++------------ src/services/storage.ts | 52 +++++++++++++++++++++++++++ src/utils/jwt.ts | 2 ++ 9 files changed, 166 insertions(+), 52 deletions(-) diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index de4cdae..caf6e15 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -234,7 +234,6 @@ export async function handlePublicDownloadAttachment( } const storage = new StorageService(env.DB); - // Verify attachment exists const attachment = await storage.getAttachment(attachmentId); @@ -250,6 +249,11 @@ export async function handlePublicDownloadAttachment( return errorResponse('Attachment file not found', 404); } + const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp); + if (!firstUse) { + return errorResponse('Invalid or expired token', 401); + } + return new Response(object.body, { headers: { 'Content-Type': 'application/octet-stream', diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 67ae181..0414c09 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -103,6 +103,7 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str return errorResponse('Folder not found', 404); } + await storage.clearFolderFromCiphers(userId, id); await storage.deleteFolder(id, userId); await storage.updateRevisionDate(userId); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 23d2dc0..7633fa4 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -12,12 +12,15 @@ export async function handleToken(request: Request, env: Env): Promise 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(); + try { + 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(); + } + } catch { + return identityErrorResponse('Invalid request payload', 'invalid_request', 400); } const grantType = body.grant_type; @@ -108,12 +111,12 @@ export async function handleToken(request: Request, env: Env): Promise // Refresh token const refreshToken = body.refresh_token; if (!refreshToken) { - return errorResponse('Refresh token is required', 400); + return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } const result = await auth.refreshAccessToken(refreshToken); if (!result) { - return errorResponse('Invalid refresh token', 401); + return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400); } // Revoke old refresh token (prevent reuse) @@ -158,7 +161,7 @@ export async function handleToken(request: Request, env: Env): Promise return jsonResponse(response); } - return errorResponse('Unsupported grant type', 400); + return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400); } // POST /identity/accounts/prelogin diff --git a/src/handlers/setupPage.ts b/src/handlers/setupPage.ts index e74176d..73d78b6 100644 --- a/src/handlers/setupPage.ts +++ b/src/handlers/setupPage.ts @@ -649,8 +649,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { creating: '正在创建…', doneTitle: '初始化完成', doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:', - important: '重要提示', - limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。', hideTitle: '隐藏初始化页', hideDesc: '隐藏后,初始化页对任何人都会返回 404。你的密码库仍可正常使用。', hideBtn: '隐藏初始化页', @@ -738,8 +736,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { creating: 'Creating…', doneTitle: 'Setup complete', doneDesc: 'Your server is ready. Use this URL in Bitwarden clients:', - important: 'Important', - limitations: 'Single user only: no additional users, no master password change. If forgotten, redeploy and register again.', hideTitle: 'Hide setup page', hideDesc: 'After hiding, this page returns 404 for everyone. Vault still works.', hideBtn: 'Hide setup page', @@ -843,8 +839,6 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { setText('submitBtn', t('create')); setText('t_done_title', t('doneTitle')); setText('t_done_desc', t('doneDesc')); - setText('t_important', t('important')); - setText('t_limitations', t('limitations')); setText('t_hide_title', t('hideTitle')); setText('t_hide_desc', t('hideDesc')); setText('hideBtn', t('hideBtn')); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 50a8174..10fafdb 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -6,6 +6,9 @@ import { cipherToResponse } from './ciphers'; // GET /api/sync export async function handleSync(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); + const url = new URL(request.url); + const excludeDomainsParam = url.searchParams.get('excludeDomains'); + const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); const user = await storage.getUserById(userId); if (!user) { @@ -61,11 +64,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr folders: folderResponses, collections: [], ciphers: cipherResponses, - domains: { - equivalentDomains: [], - globalEquivalentDomains: [], - object: 'domains', - }, + domains: excludeDomains + ? null + : { + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }, policies: [], sends: [], // PascalCase for desktop/browser clients @@ -81,7 +86,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, MasterKeyEncryptedUserKey: user.key, MasterKeyWrappedUserKey: user.key, - Salt: user.email, + Salt: user.email.toLowerCase(), Object: 'masterPasswordUnlock', }, }, @@ -96,7 +101,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr }, masterKeyWrappedUserKey: user.key, masterKeyEncryptedUserKey: user.key, - salt: user.email, + salt: user.email.toLowerCase(), }, }, object: 'sync', diff --git a/src/router.ts b/src/router.ts index 659aa02..9ab3d06 100644 --- a/src/router.ts +++ b/src/router.ts @@ -49,6 +49,26 @@ import { handlePublicDownloadAttachment, } from './handlers/attachments'; +function isSameOriginWriteRequest(request: Request): boolean { + const targetOrigin = new URL(request.url).origin; + const origin = request.headers.get('Origin'); + if (origin) { + return origin === targetOrigin; + } + + const referer = request.headers.get('Referer'); + if (referer) { + try { + return new URL(referer).origin === targetOrigin; + } catch { + return false; + } + } + + // Require browser-origin evidence for setup/register write operations. + return false; +} + function getNwIconSvg(): string { return `NW`; } @@ -63,19 +83,54 @@ function handleNwFavicon(): Response { }); } +function isValidIconHostname(hostname: string): boolean { + if (!hostname) return false; + if (hostname.length > 253) return false; + + const normalized = hostname.toLowerCase(); + const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; + + if (domainPattern.test(normalized)) return true; + if (!ipv4Pattern.test(normalized)) return false; + + const parts = normalized.split('.'); + return parts.every(p => { + const n = Number(p); + return Number.isInteger(n) && n >= 0 && n <= 255; + }); +} + // Icons handler - proxy to Bitwarden's official icon service async function handleGetIcon(request: Request, env: Env, hostname: string): Promise { try { + void env; + const normalizedHostname = hostname.toLowerCase(); + if (!isValidIconHostname(normalizedHostname)) { + return new Response(null, { status: 204 }); + } + + const cache = caches.default; + const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' }); + const cached = await cache.match(cacheKey); + if (cached) { + return cached; + } + // Use Bitwarden's official icon service - const iconUrl = `https://icons.bitwarden.net/${hostname}/icon.png`; + const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`; const resp = await fetch(iconUrl, { headers: { 'User-Agent': 'NodeWarden/1.0' }, redirect: 'follow', + cf: { + cacheEverything: true, + cacheTtl: 604800, + }, }); if (resp.ok) { const body = await resp.arrayBuffer(); - return new Response(body, { + const iconResponse = new Response(body, { status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', @@ -83,6 +138,8 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom 'Access-Control-Allow-Origin': '*', }, }); + await cache.put(cacheKey, iconResponse.clone()); + return iconResponse; } return new Response(null, { status: 204 }); @@ -116,6 +173,9 @@ export async function handleRequest(request: Request, env: Env): Promise { + // Atomically consume one write budget unit for the current fixed window. + // Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. + async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { const nowSec = Math.floor(Date.now() / 1000); const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; const row = await this.db - .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') - .bind(identifier, windowStart) + .prepare( + 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + + 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' + + 'WHERE api_rate_limits.count < ? ' + + 'RETURNING count' + ) + .bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE) .first<{ count: number }>(); - const count = row?.count || 0; - if (count >= CONFIG.API_WRITE_REQUESTS_PER_MINUTE) { + // No returned row means conflict happened and WHERE prevented the increment: + // current count is already at/above the configured limit. + if (!row) { return { allowed: false, remaining: 0, @@ -132,24 +140,8 @@ export class RateLimitService { }; } - return { - allowed: true, - remaining: CONFIG.API_WRITE_REQUESTS_PER_MINUTE - count, - }; - } - - async incrementApiCount(identifier: string): Promise { - const nowSec = Math.floor(Date.now() / 1000); - const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); - - // Atomic increment via UPSERT. - await this.db - .prepare( - 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + - 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1' - ) - .bind(identifier, windowStart) - .run(); + const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count); + return { allowed: true, remaining }; } } diff --git a/src/services/storage.ts b/src/services/storage.ts index a661bf9..ea27878 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -7,6 +7,8 @@ import { User, Cipher, Folder, Attachment } from '../types'; // - Revision date is maintained per user for Bitwarden sync. export class StorageService { + private static attachmentTokenTableReady = false; + constructor(private db: D1Database) {} /** @@ -395,6 +397,23 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); } + // Clear folder references from all ciphers owned by the user. + // Without this, deleting a folder leaves stale folderId values in cipher JSON. + async clearFolderFromCiphers(userId: string, folderId: string): Promise { + const now = new Date().toISOString(); + const res = await this.db + .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') + .bind(userId, folderId) + .all<{ data: string }>(); + + for (const row of (res.results || [])) { + const cipher = JSON.parse(row.data) as Cipher; + cipher.folderId = null; + cipher.updatedAt = now; + await this.saveCipher(cipher); + } + } + async getAllFolders(userId: string): Promise { const res = await this.db .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') @@ -579,4 +598,37 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); .run(); return date; } + + // --- One-time attachment download tokens --- + + private async ensureUsedAttachmentDownloadTokenTable(): Promise { + if (StorageService.attachmentTokenTableReady) return; + + await this.db.prepare( + 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + + 'jti TEXT PRIMARY KEY, ' + + 'expires_at INTEGER NOT NULL' + + ')' + ).run(); + + StorageService.attachmentTokenTableReady = true; + } + + // Marks an attachment download token JTI as consumed. + // Returns true only on first use. Reuse returns false. + async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise { + await this.ensureUsedAttachmentDownloadTokenTable(); + + const nowMs = Date.now(); + // Best-effort cleanup of expired entries. + await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + + const expiresAtMs = expUnixSeconds * 1000; + const result = await this.db.prepare( + 'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' + + 'ON CONFLICT(jti) DO NOTHING' + ).bind(jti, expiresAtMs).run(); + + return (result.meta.changes ?? 0) > 0; + } } diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index f1a399a..fc8f0b8 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -99,6 +99,7 @@ export function createRefreshToken(): string { export interface FileDownloadClaims { cipherId: string; attachmentId: string; + jti: string; exp: number; } @@ -114,6 +115,7 @@ export async function createFileDownloadToken( const payload: FileDownloadClaims = { cipherId, attachmentId, + jti: createRefreshToken(), exp: now + 300, // 5 minutes }; From 2a747c996dbeb50768ec270c6d7d054b3d692fbf Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Feb 2026 20:59:46 +0800 Subject: [PATCH 010/149] feat(pagination): add pagination utility functions for handling page size and continuation tokens - Introduced `PaginationRequest` interface to define pagination parameters. - Implemented `parsePagination` function to extract and validate pagination parameters from a URL. - Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens. - Ensured that pagination respects maximum page size limits defined in configuration. --- migrations/0001_init.sql | 9 +- src/config/limits.ts | 104 ++++++ src/handlers/accounts.ts | 27 +- src/handlers/attachments.ts | 6 +- src/handlers/ciphers.ts | 30 +- src/handlers/folders.ts | 17 +- src/handlers/identity.ts | 7 +- src/handlers/setup.ts | 16 +- src/handlers/sync.ts | 53 +++- src/index.ts | 28 +- src/router.ts | 45 ++- src/services/ratelimit.ts | 115 +++++-- src/services/storage.ts | 295 +++++++++--------- .../setupPage.ts => setup/pageTemplate.ts} | 20 +- src/utils/jwt.ts | 7 +- src/utils/pagination.ts | 38 +++ src/utils/response.ts | 83 ++++- tests/selfcheck.ts | 30 +- 18 files changed, 688 insertions(+), 242 deletions(-) create mode 100644 src/config/limits.ts rename src/{handlers/setupPage.ts => setup/pageTemplate.ts} (98%) create mode 100644 src/utils/pagination.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index f24e092..1cf76d0 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); -- Rate limiting -CREATE TABLE IF NOT EXISTS login_attempts ( - email TEXT PRIMARY KEY, +CREATE TABLE IF NOT EXISTS login_attempts_ip ( + ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL @@ -92,3 +92,8 @@ CREATE TABLE IF NOT EXISTS api_rate_limits ( PRIMARY KEY (identifier, window_start) ); CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); + +CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( + jti TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL +); diff --git a/src/config/limits.ts b/src/config/limits.ts new file mode 100644 index 0000000..fdaedde --- /dev/null +++ b/src/config/limits.ts @@ -0,0 +1,104 @@ +export const LIMITS = { + auth: { + // Access token lifetime in seconds. + // 访问令牌有效期(秒)。 + accessTokenTtlSeconds: 7200, + // Refresh token lifetime in milliseconds. + // 刷新令牌有效期(毫秒)。 + refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, + // Refresh token random byte length. + // 刷新令牌随机字节长度。 + refreshTokenRandomBytes: 32, + // Attachment download token lifetime in seconds. + // 附件下载令牌有效期(秒)。 + fileDownloadTokenTtlSeconds: 300, + // Minimum required JWT secret length. + // JWT 密钥最小长度要求。 + jwtSecretMinLength: 32, + // Default PBKDF2 iterations for account creation/prelogin fallback. + // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 + defaultKdfIterations: 600000, + }, + rateLimit: { + // Max failed login attempts before temporary lock. + // 触发临时锁定前允许的最大登录失败次数。 + loginMaxAttempts: 5, + // Login lock duration in minutes. + // 登录锁定时长(分钟)。 + loginLockoutMinutes: 2, + // Write API request budget per minute. + // 写操作 API 每分钟请求配额。 + apiWriteRequestsPerMinute: 120, + // /api/sync read request budget per minute. + // /api/sync 读请求每分钟配额。 + syncReadRequestsPerMinute: 1000, + // Fixed window size for API rate limiting in seconds. + // API 限流固定窗口大小(秒)。 + apiWindowSeconds: 60, + // Probability to run low-frequency cleanup on request path. + // 在请求路径中触发低频清理的概率。 + cleanupProbability: 0.05, + // Minimum interval between login-attempt cleanup runs. + // 登录尝试表清理的最小间隔。 + loginIpCleanupIntervalMs: 10 * 60 * 1000, + // Minimum interval between API-window cleanup runs. + // API 窗口计数清理的最小间隔。 + apiWindowCleanupIntervalMs: 5 * 60 * 1000, + // Retention window for login IP records. + // 登录 IP 记录保留时长。 + loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000, + // Number of historical API windows to keep. + // 保留的历史 API 窗口数量。 + apiWindowRetentionWindows: 120, + }, + cleanup: { + // Minimum interval between refresh-token cleanup runs. + // refresh_token 表清理最小间隔。 + refreshTokenCleanupIntervalMs: 30 * 60 * 1000, + // Minimum interval between used attachment token cleanup runs. + // 已使用附件令牌表清理最小间隔。 + attachmentTokenCleanupIntervalMs: 10 * 60 * 1000, + // Probability to trigger cleanup during requests. + // 请求过程中触发清理的概率。 + cleanupProbability: 0.05, + }, + attachment: { + // Max attachment upload size in bytes. + // 附件上传大小上限(字节)。 + maxFileSizeBytes: 100 * 1024 * 1024, + }, + pagination: { + // Default page size when client does not specify pageSize. + // 客户端未传 pageSize 时的默认分页大小。 + defaultPageSize: 100, + // Hard maximum page size accepted by server. + // 服务端允许的最大分页大小。 + maxPageSize: 500, + }, + cors: { + // Browser preflight cache max age in seconds. + // 浏览器预检请求缓存时长(秒)。 + preflightMaxAgeSeconds: 86400, + }, + cache: { + // Icon proxy cache TTL in seconds. + // 图标代理缓存时长(秒)。 + iconTtlSeconds: 604800, + // In-memory /api/sync response cache TTL (milliseconds). + // /api/sync 内存缓存有效期(毫秒)。 + syncResponseTtlMs: 30 * 1000, + // Max in-memory /api/sync cache entries per isolate. + // 每个 isolate 的 /api/sync 最大缓存条目数。 + syncResponseMaxEntries: 64, + }, + performance: { + // Max IDs per SQL batch when moving ciphers in bulk. + // 批量移动密码项时每批 SQL 的最大 ID 数量。 + bulkMoveChunkSize: 200, + }, + compatibility: { + // Single source of truth for /config.version and /api/version. + // /config.version 与 /api/version 的统一版本号来源。 + bitwardenServerVersion: '2025.12.0', + }, +} as const; diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 245ab40..c80dcf4 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -3,12 +3,23 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { LIMITS } from '../config/limits'; + +function looksLikeEncString(value: string): boolean { + if (!value) return false; + const firstDot = value.indexOf('.'); + if (firstDot <= 0 || firstDot === value.length - 1) return false; + const payload = value.slice(firstDot + 1); + const parts = payload.split('|'); + // Bitwarden encrypted payloads should have at least IV + ciphertext. + return parts.length >= 2; +} function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { const secret = (env.JWT_SECRET || '').trim(); if (!secret) return 'missing'; if (secret === DEFAULT_DEV_SECRET) return 'default'; - if (secret.length < 32) return 'too_short'; + if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; return null; } @@ -63,6 +74,12 @@ export async function handleRegister(request: Request, env: Env): Promise { const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) { + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { return errorResponse('Server configuration error', 500); } @@ -259,7 +260,6 @@ export async function handlePublicDownloadAttachment( 'Content-Type': 'application/octet-stream', 'Content-Length': String(object.size), 'Cache-Control': 'private, no-cache', - 'Access-Control-Allow-Origin': '*', }, }); } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index d38f50e..28ef9fb 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -3,6 +3,7 @@ import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher } from './attachments'; +import { parsePagination, encodeContinuationToken } from '../utils/pagination'; // Format attachments for API response export function formatAttachments(attachments: Attachment[]): any[] | null { @@ -53,15 +54,28 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []) // GET /api/ciphers export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); - 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); + const pagination = parsePagination(url); + + let filteredCiphers: Cipher[]; + let continuationToken: string | null = null; + if (pagination) { + const pageRows = await storage.getCiphersPage( + userId, + includeDeleted, + pagination.limit + 1, + pagination.offset + ); + const hasNext = pageRows.length > pagination.limit; + filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; + continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null; + } else { + const ciphers = await storage.getAllCiphers(userId); + filteredCiphers = includeDeleted + ? ciphers + : ciphers.filter(c => !c.deletedAt); + } const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id)); @@ -75,7 +89,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin return jsonResponse({ data: cipherResponses, object: 'list', - continuationToken: null, + continuationToken: continuationToken, }); } diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 0414c09..9c20870 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -2,6 +2,7 @@ import { Env, Folder, FolderResponse } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { parsePagination, encodeContinuationToken } from '../utils/pagination'; // Convert internal folder to API response format function folderToResponse(folder: Folder): FolderResponse { @@ -16,12 +17,24 @@ function folderToResponse(folder: Folder): FolderResponse { // GET /api/folders export async function handleGetFolders(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); - const folders = await storage.getAllFolders(userId); + const url = new URL(request.url); + const pagination = parsePagination(url); + + let folders: Folder[]; + let continuationToken: string | null = null; + if (pagination) { + const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset); + const hasNext = pageRows.length > pagination.limit; + folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; + continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null; + } else { + folders = await storage.getAllFolders(userId); + } return jsonResponse({ data: folders.map(folderToResponse), object: 'list', - continuationToken: null, + continuationToken: continuationToken, }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 7633fa4..6f0cdb2 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -3,6 +3,7 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; // POST /identity/connect/token export async function handleToken(request: Request, env: Env): Promise { @@ -74,7 +75,7 @@ export async function handleToken(request: Request, env: Env): Promise const response: TokenResponse = { access_token: accessToken, - expires_in: 7200, + expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', refresh_token: refreshToken, Key: user.key, @@ -127,7 +128,7 @@ export async function handleToken(request: Request, env: Env): Promise const response: TokenResponse = { access_token: accessToken, - expires_in: 7200, + expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', refresh_token: newRefreshToken, Key: user.key, @@ -184,7 +185,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const disabled = await storage.isSetupDisabled(); + if (disabled) { + return new Response(null, { status: 404 }); + } + return htmlResponse(renderRegisterPageHTML(jwtState)); +} + // GET / - Setup page export async function handleSetupPage(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 10fafdb..ee24bba 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -1,7 +1,40 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types'; import { StorageService } from '../services/storage'; -import { jsonResponse, errorResponse } from '../utils/response'; +import { errorResponse } from '../utils/response'; import { cipherToResponse } from './ciphers'; +import { LIMITS } from '../config/limits'; + +interface SyncCacheEntry { + body: string; + expiresAt: number; +} + +const syncResponseCache = new Map(); + +function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string { + return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`; +} + +function readSyncCache(key: string): string | null { + const hit = syncResponseCache.get(key); + if (!hit) return null; + if (hit.expiresAt <= Date.now()) { + syncResponseCache.delete(key); + return null; + } + return hit.body; +} + +function writeSyncCache(key: string, body: string): void { + if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) { + const oldestKey = syncResponseCache.keys().next().value as string | undefined; + if (oldestKey) syncResponseCache.delete(oldestKey); + } + syncResponseCache.set(key, { + body, + expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs, + }); +} // GET /api/sync export async function handleSync(request: Request, env: Env, userId: string): Promise { @@ -15,6 +48,16 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr return errorResponse('User not found', 404); } + const revisionDate = await storage.getRevisionDate(userId); + const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains); + const cachedBody = readSyncCache(cacheKey); + if (cachedBody) { + return new Response(cachedBody, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + const ciphers = await storage.getAllCiphers(userId); const folders = await storage.getAllFolders(userId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id)); @@ -107,5 +150,11 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr object: 'sync', }; - return jsonResponse(syncResponse); + const body = JSON.stringify(syncResponse); + writeSyncCache(cacheKey, body); + + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); } diff --git a/src/index.ts b/src/index.ts index 322a1c2..2983a8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import { Env } from './types'; import { handleRequest } from './router'; import { StorageService } from './services/storage'; +import { applyCors, jsonResponse } from './utils/response'; -// Per-isolate flag. Each Worker isolate may have its own copy of this flag, -// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS), -// so redundant calls are harmless and fast (single SELECT check). +// Per-isolate flags. Each Worker isolate may have its own copy of these flags. +// initializeDatabase() only validates schema presence, so retries are cheap. let dbInitialized = false; +let dbInitError: string | null = null; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -15,12 +16,29 @@ export default { const storage = new StorageService(env.DB); await storage.initializeDatabase(); dbInitialized = true; + dbInitError = null; } catch (error) { console.error('Failed to initialize database:', error); - // Continue anyway - the error will surface when actual DB operations are attempted + dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error'; } } - return handleRequest(request, env); + if (dbInitError) { + const resp = jsonResponse( + { + error: 'Database not initialized', + error_description: dbInitError, + ErrorModel: { + Message: dbInitError, + Object: 'error', + }, + }, + 500 + ); + return applyCors(request, resp); + } + + const resp = await handleRequest(request, env); + return applyCors(request, resp); }, }; diff --git a/src/router.ts b/src/router.ts index 9ab3d06..5bb42d1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,6 +2,7 @@ import { Env, DEFAULT_DEV_SECRET } from './types'; import { AuthService } from './services/auth'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { handleCors, errorResponse, jsonResponse } from './utils/response'; +import { LIMITS } from './config/limits'; // Identity handlers import { handleToken, handlePrelogin } from './handlers/identity'; @@ -78,7 +79,7 @@ function handleNwFavicon(): Response { status: 200, headers: { 'Content-Type': 'image/svg+xml; charset=utf-8', - 'Cache-Control': 'public, max-age=604800', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, }, }); } @@ -87,8 +88,11 @@ function isValidIconHostname(hostname: string): boolean { if (!hostname) return false; if (hostname.length > 253) return false; - const normalized = hostname.toLowerCase(); - const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + // Slightly relaxed domain validation: + // - keep strict label boundaries (no leading/trailing hyphen) + // - allow punycode TLD (e.g. xn--...) + const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/; const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; if (domainPattern.test(normalized)) return true; @@ -124,7 +128,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom redirect: 'follow', cf: { cacheEverything: true, - cacheTtl: 604800, + cacheTtl: LIMITS.cache.iconTtlSeconds, }, }); @@ -134,8 +138,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', - 'Cache-Control': 'public, max-age=604800', // 7 days - 'Access-Control-Allow-Origin': '*', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days }, }); await cache.put(cacheKey, iconResponse.clone()); @@ -155,7 +158,7 @@ export async function handleRequest(request: Request, env: Env): Promise { + if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) { + return; + } + + const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS; + await this.db + .prepare( + 'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)' + ) + .bind(cutoff, nowMs) + .run(); + RateLimitService.lastLoginIpCleanupAt = nowMs; + } + + private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise { + if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) { + return; + } + + const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS); + await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run(); + RateLimitService.lastApiWindowCleanupAt = Date.now(); + } + private async ensureLoginIpTable(): Promise { if (RateLimitService.loginIpTableReady) return; @@ -45,6 +88,7 @@ export class RateLimitService { const key = ip.trim() || 'unknown'; const now = Date.now(); + await this.maybeCleanupLoginAttemptsIp(now); const row = await this.db .prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?') @@ -77,10 +121,11 @@ export class RateLimitService { const key = ip.trim() || 'unknown'; const now = Date.now(); + await this.maybeCleanupLoginAttemptsIp(now); // D1 in Workers forbids raw BEGIN/COMMIT statements. // Use a single atomic UPSERT to increment attempts. - // This is concurrency-safe because the row is keyed by email. + // This is concurrency-safe because the row is keyed by IP. await this.db .prepare( 'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + @@ -113,26 +158,30 @@ export class RateLimitService { await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run(); } - // Atomically consume one write budget unit for the current fixed window. + // Atomically consume one budget unit for the current fixed window. // Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. - async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + private async consumeFixedWindowBudget( + identifier: string, + maxRequests: number, + windowSeconds: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { const nowSec = Math.floor(Date.now() / 1000); - const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); - const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; + const windowStart = nowSec - (nowSec % windowSeconds); + const windowEnd = windowStart + windowSeconds; + await this.maybeCleanupApiWindows(windowStart, windowSeconds); - const row = await this.db + const writeResult = await this.db .prepare( 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' + - 'WHERE api_rate_limits.count < ? ' + - 'RETURNING count' + 'WHERE api_rate_limits.count < ?' ) - .bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE) - .first<{ count: number }>(); + .bind(identifier, windowStart, maxRequests) + .run(); - // No returned row means conflict happened and WHERE prevented the increment: - // current count is already at/above the configured limit. - if (!row) { + // No changed row means conflict happened and WHERE prevented increment: + // current count is already at/above configured limit. + if ((writeResult.meta.changes ?? 0) === 0) { return { allowed: false, remaining: 0, @@ -140,9 +189,39 @@ export class RateLimitService { }; } - const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count); + const row = await this.db + .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') + .bind(identifier, windowStart) + .first<{ count: number }>(); + + if (!row) { + return { + allowed: true, + remaining: 0, + }; + } + + const remaining = Math.max(0, maxRequests - row.count); return { allowed: true, remaining }; } + + // Write budget for POST/PUT/DELETE/PATCH requests. + async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.API_WRITE_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } + + // Read budget for GET /api/sync. + async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.SYNC_READ_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } } export function getClientIdentifier(request: Request): string { diff --git a/src/services/storage.ts b/src/services/storage.ts index ea27878..4f023bc 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,5 @@ import { User, Cipher, Folder, Attachment } from '../types'; +import { LIMITS } from '../config/limits'; // D1-backed storage. // Contract: @@ -8,13 +9,20 @@ import { User, Cipher, Folder, Attachment } from '../types'; export class StorageService { private static attachmentTokenTableReady = false; + private static schemaVerified = false; + private static lastRefreshTokenCleanupAt = 0; + private static lastAttachmentTokenCleanupAt = 0; + + private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; + private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; + private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; constructor(private db: D1Database) {} /** * D1 .bind() throws on `undefined` values. This helper converts every * `undefined` in the argument list to `null` so we never hit that runtime - * error — especially important after the opaque-passthrough change where + * error - especially important after the opaque-passthrough change where * client-supplied JSON may omit fields we later reference as columns. */ private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement { @@ -32,133 +40,83 @@ export class StorageService { return `sha256:${digest}`; } + private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean { + const now = Date.now(); + if (now - lastRunAt < intervalMs) return false; + return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY; + } + + private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise { + if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) { + return; + } + + await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run(); + StorageService.lastRefreshTokenCleanupAt = nowMs; + } + // --- Database initialization --- - // Idempotent auto-init for environments where D1 migrations have not been applied - // (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql — - // keep both in sync when changing the schema. - + // One-click deploy requires zero manual migration steps. + // This method idempotently creates required schema objects on first request. async initializeDatabase(): Promise { - // Check if database is already initialized by looking for the config table - try { - const result = await this.db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='config'") - .first<{ name: string }>(); - - if (result?.name === 'config') { - // Database already initialized - return; - } - } catch (e) { - // If error occurs, assume database needs initialization - console.log('Initializing database...'); + if (StorageService.schemaVerified) return; + + const schemaStatements = [ + 'PRAGMA foreign_keys = ON', + + 'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS users (' + + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + + 'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS user_revisions (' + + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + + 'CREATE TABLE IF NOT EXISTS ciphers (' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + + 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', + + 'CREATE TABLE IF NOT EXISTS folders (' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)', + + 'CREATE TABLE IF NOT EXISTS attachments (' + + 'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' + + 'size_name TEXT NOT NULL, key TEXT, ' + + 'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)', + + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', + + 'CREATE TABLE IF NOT EXISTS api_rate_limits (' + + 'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' + + 'PRIMARY KEY (identifier, window_start))', + 'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)', + + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + + 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', + ]; + + for (const stmt of schemaStatements) { + await this.db.prepare(stmt).run(); } - // Execute initialization SQL - const initSQL = ` -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - master_password_hash TEXT NOT NULL, - key TEXT NOT NULL, - private_key TEXT, - public_key TEXT, - kdf_type INTEGER NOT NULL, - kdf_iterations INTEGER NOT NULL, - kdf_memory INTEGER, - kdf_parallelism INTEGER, - security_stamp TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS user_revisions ( - user_id TEXT PRIMARY KEY, - revision_date TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ciphers ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - type INTEGER NOT NULL, - folder_id TEXT, - name TEXT, - notes TEXT, - favorite INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL, - reprompt INTEGER, - key TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); -CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); - -CREATE TABLE IF NOT EXISTS folders ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at); - -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, - cipher_id TEXT NOT NULL, - file_name TEXT NOT NULL, - size INTEGER NOT NULL, - size_name TEXT NOT NULL, - key TEXT, - FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id); - -CREATE TABLE IF NOT EXISTS refresh_tokens ( - token TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - expires_at INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); - -CREATE TABLE IF NOT EXISTS login_attempts ( - email TEXT PRIMARY KEY, - attempts INTEGER NOT NULL, - locked_until INTEGER, - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS api_rate_limits ( - identifier TEXT NOT NULL, - window_start INTEGER NOT NULL, - count INTEGER NOT NULL, - PRIMARY KEY (identifier, window_start) -); -CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); - `.trim(); - - // Split by semicolon and execute each statement - const statements = initSQL.split(';').filter(s => s.trim().length > 0); - - for (const stmt of statements) { - if (stmt.trim()) { - await this.db.prepare(stmt).run(); - } - } - - console.log('Database initialized successfully'); + StorageService.schemaVerified = true; } // --- Config / setup --- @@ -335,6 +293,21 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } + async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise { + const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; + const res = await this.db + .prepare( + `SELECT data FROM ciphers + WHERE user_id = ? + ${whereDeleted} + ORDER BY updated_at DESC + LIMIT ? OFFSET ?` + ) + .bind(userId, limit, offset) + .all<{ data: string }>(); + return (res.results || []).map(r => JSON.parse(r.data) as Cipher); + } + async getCiphersByIds(ids: string[], userId: string): Promise { if (ids.length === 0) return []; // D1 doesn't support binding arrays directly; build placeholders. @@ -347,20 +320,25 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { if (ids.length === 0) return; const now = new Date().toISOString(); + const uniqueIds = Array.from(new Set(ids)); + const patch = JSON.stringify({ + folderId, + updatedAt: now, + }); + const chunkSize = LIMITS.performance.bulkMoveChunkSize; - // D1 forbids raw BEGIN/COMMIT statements in this runtime. - // For this endpoint, we accept per-row updates and then bump revision once. - // Concurrency: each cipher write is an UPSERT on its PK, no shared index. - for (const id of ids) { - const row = await this.db - .prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?') - .bind(id, userId) - .first<{ data: string }>(); - if (!row?.data) continue; - const cipher = JSON.parse(row.data) as Cipher; - cipher.folderId = folderId; - cipher.updatedAt = now; - await this.saveCipher(cipher); + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + + await this.db + .prepare( + `UPDATE ciphers + SET folder_id = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(folderId, now, patch, userId, ...chunk) + .run(); } await this.updateRevisionDate(userId); @@ -428,6 +406,22 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); })); } + async getFoldersPage(userId: string, limit: number, offset: number): Promise { + const res = await this.db + .prepare( + 'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' + ) + .bind(userId, limit, offset) + .all(); + return (res.results || []).map(r => ({ + id: r.id, + userId: r.user_id, + name: r.name, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + } + // --- Attachments --- async getAttachment(id: string): Promise { @@ -531,7 +525,8 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); // --- Refresh tokens --- async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { - const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); + const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); + await this.maybeCleanupExpiredRefreshTokens(Date.now()); const tokenKey = await this.refreshTokenKey(token); await this.db.prepare( 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + @@ -543,6 +538,7 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async getRefreshTokenUserId(token: string): Promise { const now = Date.now(); + await this.maybeCleanupExpiredRefreshTokens(now); const tokenKey = await this.refreshTokenKey(token); let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') @@ -585,7 +581,17 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') .bind(userId) .first<{ revision_date: string }>(); - return row?.revision_date || new Date().toISOString(); + if (row?.revision_date) return row.revision_date; + + const date = new Date().toISOString(); + await this.db + .prepare( + 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + + 'ON CONFLICT(user_id) DO NOTHING' + ) + .bind(userId, date) + .run(); + return date; } async updateRevisionDate(userId: string): Promise { @@ -620,8 +626,15 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); await this.ensureUsedAttachmentDownloadTokenTable(); const nowMs = Date.now(); - // Best-effort cleanup of expired entries. - await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + if ( + this.shouldRunPeriodicCleanup( + StorageService.lastAttachmentTokenCleanupAt, + StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS + ) + ) { + await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + StorageService.lastAttachmentTokenCleanupAt = nowMs; + } const expiresAtMs = expUnixSeconds * 1000; const result = await this.db.prepare( diff --git a/src/handlers/setupPage.ts b/src/setup/pageTemplate.ts similarity index 98% rename from src/handlers/setupPage.ts rename to src/setup/pageTemplate.ts index 73d78b6..3c305b2 100644 --- a/src/handlers/setupPage.ts +++ b/src/setup/pageTemplate.ts @@ -1,11 +1,10 @@ -import { Env } from '../types'; -import { StorageService } from '../services/storage'; -import { htmlResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; -type JwtSecretState = 'missing' | 'default' | 'too_short'; +export type JwtSecretState = 'missing' | 'default' | 'too_short'; -function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { +export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { const jwtStateJson = JSON.stringify(jwtState); + const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; return ` @@ -1117,7 +1116,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { } try { - const iterations = 600000; + const iterations = ${defaultKdfIterations}; const masterKey = await pbkdf2(password, email, iterations, 32); const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32); const masterPasswordHashB64 = base64Encode(masterPasswordHash); @@ -1212,12 +1211,3 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { `; } - -export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { - const storage = new StorageService(env.DB); - const disabled = await storage.isSetupDisabled(); - if (disabled) { - return new Response(null, { status: 404 }); - } - return htmlResponse(renderRegisterPageHTML(jwtState)); -} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index fc8f0b8..67ee54e 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,4 +1,5 @@ import { JWTPayload } from '../types'; +import { LIMITS } from '../config/limits'; // Base64 URL encode function base64UrlEncode(data: Uint8Array): string { @@ -19,7 +20,7 @@ function base64UrlDecode(str: string): Uint8Array { } // Create JWT -export async function createJWT(payload: Omit, secret: string, expiresIn: number = 7200): Promise { +export async function createJWT(payload: Omit, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise { const header = { alg: 'HS256', typ: 'JWT' }; const now = Math.floor(Date.now() / 1000); @@ -90,7 +91,7 @@ export async function verifyJWT(token: string, secret: string): Promise { + const headers: Record = { + 'Access-Control-Allow-Methods': CORS_METHODS, + 'Access-Control-Allow-Headers': CORS_HEADERS, + 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), + }; + + const allowedOrigin = getAllowedOrigin(request); + if (allowedOrigin) { + headers['Access-Control-Allow-Origin'] = allowedOrigin; + headers['Vary'] = 'Origin'; + } + + return headers; +} + +export function applyCors( + request: Request, + response: Response +): Response { + const headers = new Headers(response.headers); + const corsHeaders = buildCorsHeaders(request); + for (const [k, v] of Object.entries(corsHeaders)) { + headers.set(k, v); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + // 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, }, }); @@ -40,21 +98,19 @@ export function identityErrorResponse(message: string, error: string = 'invalid_ ); } -// 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 { +export function handleCors(request: Request): Response { + const origin = request.headers.get('Origin'); + if (origin) { + const allowedOrigin = getAllowedOrigin(request); + if (!allowedOrigin) { + return new Response(null, { status: 403 }); + } + } + return new Response(null, { status: 204, - headers: getCorsHeaders(), + headers: buildCorsHeaders(request), }); } @@ -64,7 +120,6 @@ export function htmlResponse(html: string, status: number = 200): Response { status, headers: { 'Content-Type': 'text/html; charset=utf-8', - ...getCorsHeaders(), }, }); } diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts index c4abca1..aa6dc17 100644 --- a/tests/selfcheck.ts +++ b/tests/selfcheck.ts @@ -41,9 +41,9 @@ import { pbkdf2Sync, randomBytes } from 'node:crypto'; // ─── 配置 ─────────────────────────────────────────────────────────────────── // 优先取命令行参数,其次取环境变量,最后用默认值 -const BASE = (process.argv[2] || process.env.NW_URL || 'http://localhost:8787').replace(/\/+$/, ''); -const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'test@test.com').toLowerCase(); -const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'testtesttest'); +const BASE = (process.argv[2] || process.env.NW_URL || 'https://key.shuai.plus').replace(/\/+$/, ''); +const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'shuai@cock.li').toLowerCase(); +const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'rezwangul4qoxka@'); // ─── Bitwarden KDF ───────────────────────────────────────────────────────── // Bitwarden 客户端在注册和登录时,不会把明文密码发给服务器。 @@ -346,9 +346,12 @@ async function suiteCors() { group('2 · CORS 深度验证(浏览器插件必需)'); await test('OPTIONS / 返回 204 + CORS 头', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); + const resp = await fetch(`${BASE}/`, { + method: 'OPTIONS', + headers: { Origin: BASE }, + }); const acao = resp.headers.get('access-control-allow-origin'); - return { ok: resp.status === 204 && acao === '*' }; + return { ok: resp.status === 204 && acao === BASE }; }); // 浏览器插件请求 /identity/connect/token 前会发 OPTIONS 预检 @@ -384,10 +387,12 @@ async function suiteCors() { }); // 实际 JSON 响应也必须带 CORS 头(不只是 OPTIONS) - await test('JSON 响应包含 Access-Control-Allow-Origin: *', async () => { - const resp = await fetch(`${BASE}/config`); + await test('JSON 响应包含 Access-Control-Allow-Origin(同源)', async () => { + const resp = await fetch(`${BASE}/config`, { + headers: { Origin: BASE }, + }); const acao = resp.headers.get('access-control-allow-origin'); - return { ok: acao === '*' }; + return { ok: acao === BASE }; }); } @@ -662,7 +667,7 @@ async function suiteRefresh() { method: 'POST', auth: false, form: { grant_type: 'refresh_token', refresh_token: oldRefreshToken }, }); - return { ok: status === 401, detail: `状态码=${status}` }; + return { ok: status === 400 || status === 401, detail: `状态码=${status}` }; }); } @@ -835,9 +840,14 @@ async function suiteAccounts() { }); await test('POST /api/accounts/keys 更新密钥', async () => { + const current = await api('/api/accounts/profile'); + if (current.status !== 200 || !current.body?.key || !current.body?.privateKey) { + return { ok: false, detail: '无法读取当前 key/privateKey' }; + } const { status, body } = await api('/api/accounts/keys', { method: 'POST', - body: { key: userEncKey, publicKey: 'selfcheck-pubkey', encryptedPrivateKey: 'selfcheck-privkey' }, + // Non-destructive roundtrip: submit current encrypted keys as-is. + body: { key: current.body.key, encryptedPrivateKey: current.body.privateKey }, }); return { ok: status === 200 && body?.object === 'profile' }; }); From 69f4fde5a21d9492675cf7e3bcaaac852e6ba911 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Feb 2026 21:29:51 +0800 Subject: [PATCH 011/149] docs: update feature comparison table in README files for clarity and consistency --- README.md | 31 ++++++++++++++++++++----------- README_EN.md | 33 ++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 9351196..63843f9 100644 --- a/README.md +++ b/README.md @@ -8,21 +8,29 @@ English:[`README_EN.md`](./README_EN.md) > 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。 --- +## 与 Bitwarden 官方服务端能力对比 -## 特性 -- ✅ **完全免费,不需要在服务器上部署,再次感谢大善人!** -- ✅ 数据存储基于 Cloudflare D1(SQLite) -- ✅ 完整的密码、笔记、卡片、身份信息管理 -- ✅ 文件夹和收藏功能 -- ✅ 文件附件支持(基于 R2 存储) -- ✅ 导入/导出功能 -- ✅ 网站图标获取 -- ✅ 端到端加密(服务器无法查看明文) -- ✅ 无感更新,零停机 +| 能力项 | Bitwarden | NodeWarden | 说明 | +|---|---|---|---| +| 单用户保管库(登录/笔记/卡片/身份) | ✅ | ✅ | 基于Cloudflare D1 | +| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 | +| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 | +| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 | +| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 | +| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` | +| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 | +| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | +| 完整 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 没必要实现 | +| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | +| Send | ✅ | ❌ | 基本没人用 | +| 紧急访问 | ✅ | ❌ | 没必要实现 | +| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 | +| 推送通知完整链路 | ✅ | ❌ | 没必要实现 | ## 测试情况: + - ✅ Windows 客户端(v2026.1.0) -- ✅ Android App(v2026.1.0) +- ✅ 手机 App(v2026.1.0) - ✅ 浏览器扩展(v2026.1.0) - ⬜ macOS 客户端(未测试) - ⬜ Linux 客户端(未测试) @@ -75,3 +83,4 @@ LGPL-3.0 License - [Bitwarden](https://bitwarden.com/) - 原始设计和客户端 - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考 - [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 + diff --git a/README_EN.md b/README_EN.md index 1639d99..eae9959 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,17 +10,25 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. --- -## Features +## Feature Comparison Table (vs Official Bitwarden Server) + +| Capability | Bitwarden | NodeWarden | Notes | +|---|---|---|---| +| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported | +| Folders / Favorites | ✅ | ✅ | Common vault organization supported | +| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation | +| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 | +| Import flow (common clients) | ✅ | ✅ | Common import paths covered | +| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` | +| Multi-user | ✅ | ❌ | NodeWarden is single-user by design | +| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | +| Full 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not necessary to implement | +| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement | +| Send | ✅ | ❌ | Not necessary to implement | +| Emergency access | ✅ | ❌ | Not necessary to implement | +| Admin console / Billing & subscription | ✅ | ❌ | Free only | +| Full push notification pipeline | ✅ | ❌ | Not necessary to implement | -- ✅ **Completely free, no server deployment needed. Thanks again to the generous sponsor!** -- ✅ Data storage on Cloudflare D1 (SQLite) -- ✅ Full support for logins, notes, cards, and identities -- ✅ Folders and favorites -- ✅ Attachments (Cloudflare R2) -- ✅ Import / export -- ✅ Website icons -- ✅ End-to-end encryption (the server can’t see plaintext) -- ✅ Seamless updates, zero downtime ## Tested clients / platforms @@ -77,4 +85,7 @@ LGPL-3.0 License - [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 \ No newline at end of file +- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform + + + From ba9710cdf0c60d7e879c36083163aa3185b6323c Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 01:42:55 +0800 Subject: [PATCH 012/149] fix(storage): optimize attachment retrieval by batching cipher IDs to improve performance --- src/services/storage.ts | 44 +++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/services/storage.ts b/src/services/storage.ts index 4f023bc..cd5b0ce 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -471,26 +471,32 @@ export class StorageService { const grouped = new Map(); if (cipherIds.length === 0) return grouped; - const placeholders = cipherIds.map(() => '?').join(','); - const res = await this.db - .prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`) - .bind(...cipherIds) - .all(); + const uniqueCipherIds = [...new Set(cipherIds)]; + const chunkSize = LIMITS.performance.bulkMoveChunkSize; - for (const row of (res.results || [])) { - const item: Attachment = { - id: row.id, - cipherId: row.cipher_id, - fileName: row.file_name, - size: row.size, - sizeName: row.size_name, - key: row.key, - }; - const list = grouped.get(item.cipherId); - if (list) { - list.push(item); - } else { - grouped.set(item.cipherId, [item]); + for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { + const chunk = uniqueCipherIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await this.db + .prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`) + .bind(...chunk) + .all(); + + for (const row of (res.results || [])) { + const item: Attachment = { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; + const list = grouped.get(item.cipherId); + if (list) { + list.push(item); + } else { + grouped.set(item.cipherId, [item]); + } } } From 9edaa647c4ef7f8634669d0c985c62590f958a29 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 02:27:56 +0800 Subject: [PATCH 013/149] feat(storage): add method to retrieve attachments by user ID for improved data handling --- src/handlers/ciphers.ts | 2 +- src/handlers/import.ts | 60 +++++++++++++++++++++++++++++++++++++++-- src/handlers/sync.ts | 2 +- src/services/storage.ts | 32 ++++++++++++++++++++++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 28ef9fb..dd4b8b9 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -77,7 +77,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin : ciphers.filter(c => !c.deletedAt); } - const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id)); + const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); // Get attachments for all ciphers const cipherResponses = []; diff --git a/src/handlers/import.ts b/src/handlers/import.ts index 106cd48..4fde9ac 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -2,6 +2,7 @@ import { Env, Cipher, Folder, CipherType } from '../types'; import { StorageService } from '../services/storage'; import { errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { LIMITS } from '../config/limits'; // Bitwarden client import request format interface CiphersImportRequest { @@ -66,6 +67,17 @@ interface CiphersImportRequest { }>; } +function bindNull(v: any): any { + return v === undefined ? null : v; +} + +async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise { + for (let i = 0; i < statements.length; i += chunkSize) { + const chunk = statements.slice(i, i + chunkSize); + await db.batch(chunk); + } +} + // POST /api/ciphers/import - Bitwarden client import endpoint export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); @@ -82,9 +94,11 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st const folderRelationships = importData.folderRelationships || []; const now = new Date().toISOString(); + const batchChunkSize = LIMITS.performance.bulkMoveChunkSize; // Create folders and build index -> id mapping const folderIdMap = new Map(); + const folderRows: Folder[] = []; for (let i = 0; i < folders.length; i++) { const folderId = generateUUID(); @@ -98,7 +112,19 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st updatedAt: now, }; - await storage.saveFolder(folder); + folderRows.push(folder); + } + + if (folderRows.length > 0) { + const folderStatements = folderRows.map(folder => + env.DB + .prepare( + 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' + ) + .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) + ); + await runBatchInChunks(env.DB, folderStatements, batchChunkSize); } // Build cipher index -> folder id mapping from relationships @@ -111,6 +137,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st } // Create ciphers + const cipherRows: Cipher[] = []; for (let i = 0; i < ciphers.length; i++) { const c = ciphers[i]; const folderId = cipherFolderMap.get(i) || null; @@ -181,7 +208,36 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st deletedAt: null, }; - await storage.saveCipher(cipher); + cipherRows.push(cipher); + } + + if (cipherRows.length > 0) { + const cipherStatements = cipherRows.map(cipher => { + const data = JSON.stringify(cipher); + return env.DB + .prepare( + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' + ) + .bind( + cipher.id, + cipher.userId, + Number(cipher.type) || 1, + bindNull(cipher.folderId), + bindNull(cipher.name), + bindNull(cipher.notes), + cipher.favorite ? 1 : 0, + data, + bindNull(cipher.reprompt ?? 0), + bindNull(cipher.key), + cipher.createdAt, + cipher.updatedAt, + bindNull(cipher.deletedAt) + ); + }); + await runBatchInChunks(env.DB, cipherStatements, batchChunkSize); } // Update revision date diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index ee24bba..0c4bb7b 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -60,7 +60,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const ciphers = await storage.getAllCiphers(userId); const folders = await storage.getAllFolders(userId); - const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id)); + const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); // Build profile response const profile: ProfileResponse = { diff --git a/src/services/storage.ts b/src/services/storage.ts index cd5b0ce..1aae936 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -503,6 +503,38 @@ export class StorageService { return grouped; } + async getAttachmentsByUserId(userId: string): Promise> { + const grouped = new Map(); + const res = await this.db + .prepare( + `SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key + FROM attachments a + INNER JOIN ciphers c ON c.id = a.cipher_id + WHERE c.user_id = ?` + ) + .bind(userId) + .all(); + + for (const row of (res.results || [])) { + const item: Attachment = { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; + const list = grouped.get(item.cipherId); + if (list) { + list.push(item); + } else { + grouped.set(item.cipherId, [item]); + } + } + + return grouped; + } + async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { // Kept for API compatibility; no-op because attachments table already links cipher_id. // We still validate that the attachment exists and belongs to cipher. From 7cdccde684c0ab510c052bbc23b857c7c324a588 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 16:08:08 +0800 Subject: [PATCH 014/149] docs: add Star History section to README files --- README.md | 3 +++ README_EN.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 63843f9..2f39af7 100644 --- a/README.md +++ b/README.md @@ -83,4 +83,7 @@ LGPL-3.0 License - [Bitwarden](https://bitwarden.com/) - 原始设计和客户端 - [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考 - [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台 +--- +## Star History +[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) \ No newline at end of file diff --git a/README_EN.md b/README_EN.md index eae9959..a09243a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -89,3 +89,7 @@ LGPL-3.0 License +--- +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=shuaiplus/NodeWarden&type=timeline&legend=top-left)](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left) \ No newline at end of file From c0a390baa54e919d116c20e09b510b5eefb1ea5e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 18:57:23 +0800 Subject: [PATCH 015/149] Refactor code structure for improved readability and maintainability --- .gitignore | 1 + package.json | 3 +- tests/selfcheck.ts | 1772 -------------------------------------------- 3 files changed, 2 insertions(+), 1774 deletions(-) delete mode 100644 tests/selfcheck.ts diff --git a/.gitignore b/.gitignore index 7924ee7..9cd77ce 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .dev.vars wrangler.my.toml RELEASE_NOTES.md +tests/selfcheck.ts # Build output dist/ diff --git a/package.json b/package.json index 2487f54..7264213 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "scripts": { "dev": "wrangler dev -c wrangler.toml", "deploymy": "wrangler deploy -c wrangler.my.toml", - "deploy": "wrangler deploy", - "selfcheck": "npx tsx tests/selfcheck.ts" + "deploy": "wrangler deploy" }, "keywords": [ "bitwarden", diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts deleted file mode 100644 index aa6dc17..0000000 --- a/tests/selfcheck.ts +++ /dev/null @@ -1,1772 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * ╔══════════════════════════════════════════════════════════════╗ - * ║ NodeWarden 自查程序 — Bitwarden API 兼容性全面诊断 ║ - * ╚══════════════════════════════════════════════════════════════╝ - * - * 功能:自动验证 NodeWarden 服务端的所有 API 端点,确保兼容 - * Bitwarden 全平台客户端(Windows / Android / iOS / 浏览器 - * 插件 / Linux / macOS / CLI)。 - * - * 核心特性: - * · 内置 Bitwarden 标准 KDF(PBKDF2-SHA256),输入明文密码即可 - * · 自动注册(全新实例)或自动登录(已有用户) - * · 空保管库锁定/解锁回归测试(历史 bug 场景) - * · JWT 内部声明验证(移动端依赖) - * · 多客户端平台兼容性验证(不同 client_id、设备头) - * · CORS 深度验证(浏览器插件依赖) - * · 覆盖全部已实现端点 + 未实现端点差距分析 - * · 响应结构合规性校验(字段、格式、嵌套结构) - * · 带颜色的分组输出 + 汇总报告 - * - * 用法: - * npx tsx tests/selfcheck.ts [服务器地址] [邮箱] [明文密码] - * - * 示例: - * npx tsx tests/selfcheck.ts http://localhost:8787 test@test.com testtesttest - * - * 也可以通过环境变量传入(优先级低于命令行参数): - * NW_URL=http://localhost:8787 - * NW_EMAIL=test@test.com - * NW_PASSWORD=testtesttest - * - * 注意: - * · 运行前请确保 NodeWarden 服务器已启动(npm run dev) - * · 自查会创建测试数据(文件夹、密码项等),测试结束后会自动清理 - * · 如果是全新数据库,会自动用提供的邮箱和密码注册第一个用户 - */ - -import { pbkdf2Sync, randomBytes } from 'node:crypto'; - -// ─── 配置 ─────────────────────────────────────────────────────────────────── -// 优先取命令行参数,其次取环境变量,最后用默认值 - -const BASE = (process.argv[2] || process.env.NW_URL || 'https://key.shuai.plus').replace(/\/+$/, ''); -const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'shuai@cock.li').toLowerCase(); -const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'rezwangul4qoxka@'); - -// ─── Bitwarden KDF ───────────────────────────────────────────────────────── -// Bitwarden 客户端在注册和登录时,不会把明文密码发给服务器。 -// 流程: -// 1. prelogin 获取 KDF 参数(kdfType, kdfIterations) -// 2. masterKey = PBKDF2-SHA256(password, salt=email, iterations, 32字节) -// 3. masterPasswordHash = Base64( PBKDF2-SHA256(masterKey, salt=password, 1次, 32字节) ) -// 4. 把 masterPasswordHash 发给服务器 -// -// 下面的函数实现了这套标准流程。 - -/** - * 计算 Bitwarden 的 masterPasswordHash - * @param password - 用户明文密码 - * @param email - 用户邮箱(小写,作为盐) - * @param kdfType - KDF 类型(0=PBKDF2, 1=Argon2id) - * @param iterations - KDF 迭代次数 - * @returns Base64 编码的 masterPasswordHash - */ -function computePasswordHash(password: string, email: string, kdfType: number, iterations: number): string { - if (kdfType !== 0) { - throw new Error(`不支持的 KDF 类型: ${kdfType}(仅支持 PBKDF2=0)`); - } - // 第一步:用邮箱作为盐,对密码做 PBKDF2 派生 → masterKey(32字节) - const masterKey = pbkdf2Sync(password, email, iterations, 32, 'sha256'); - // 第二步:用密码作为盐,对 masterKey 再做 1 次 PBKDF2 → 最终哈希 - const hash = pbkdf2Sync(masterKey, password, 1, 32, 'sha256'); - return hash.toString('base64'); -} - -/** - * 生成假的加密密钥(注册时占位用) - * 格式模拟 Bitwarden 客户端: "2.base64IV|base64Data|base64MAC" - */ -function generateFakeEncKey(): string { - const iv = randomBytes(16).toString('base64'); - const data = randomBytes(32).toString('base64'); - const mac = randomBytes(32).toString('base64'); - return `2.${iv}|${data}|${mac}`; -} - -/** - * 解码 JWT payload(不验证签名,仅用于检查声明字段) - * Bitwarden 移动端会在本地解码 JWT 检查 email_verified、amr 等字段 - */ -function decodeJwtPayload(token: string): Record | null { - try { - const parts = token.split('.'); - if (parts.length !== 3) return null; - // JWT 的 base64url 编码需要转换为标准 base64 - let b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); - while (b64.length % 4) b64 += '='; - return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8')); - } catch { return null; } -} - -// ─── ANSI 颜色 ───────────────────────────────────────────────────────────── - -const c = { - reset : '\x1b[0m', - bold : '\x1b[1m', - dim : '\x1b[2m', - green : '\x1b[32m', - red : '\x1b[31m', - yellow: '\x1b[33m', - cyan : '\x1b[36m', - gray : '\x1b[90m', - white : '\x1b[97m', -}; - -// ─── 结果类型 ─────────────────────────────────────────────────────────────── - -type Status = 'PASS' | 'FAIL' | 'WARN' | 'SKIP'; - -interface TestResult { - group : string; - name : string; - status : Status; - detail? : string; - ms : number; -} - -// ─── 运行时状态 ───────────────────────────────────────────────────────────── - -let masterPasswordHash = ''; // 经 KDF 计算后的密码哈希 -let userEncKey = ''; // 用户加密密钥 -let accessToken = ''; // JWT 访问令牌 -let refreshToken = ''; // 刷新令牌 -let userId = ''; // 用户 ID -let testFolderId = ''; // 测试文件夹 ID -let testCipherId = ''; // 测试 Login 密码项 ID -let testCipher2Id = ''; // 测试 SecureNote 密码项 ID(将被永久删除) -let testAttachmentId = ''; // 测试附件 ID -let downloadToken = ''; // 附件下载令牌 -let isNewRegistration = false; - -// Track ALL test-created cipher and folder IDs so cleanup can permanently delete them. -// This prevents leftover undecryptable "[error: cannot decrypt]" items in the vault. -const allCreatedCipherIds: string[] = []; -const allCreatedFolderIds: string[] = []; - -const results: TestResult[] = []; - -// ─── HTTP 请求辅助 ───────────────────────────────────────────────────────── - -type FetchOpt = { - method? : string; - body? : any; - form? : Record; - auth? : boolean; - headers? : Record; -}; - -/** - * 统一 API 请求封装 - * @param path - 请求路径 - * @param opt - 选项:method、body(JSON)、form(表单)、auth(是否附加令牌)、headers - */ -async function api(path: string, opt: FetchOpt = {}): Promise<{ status: number; body: any; raw: Response }> { - const url = `${BASE}${path}`; - const headers: Record = { 'Accept': 'application/json', ...opt.headers }; - - if (opt.auth !== false && accessToken) { - headers['Authorization'] = `Bearer ${accessToken}`; - } - - let reqBody: string | undefined; - if (opt.form) { - headers['Content-Type'] = 'application/x-www-form-urlencoded'; - reqBody = new URLSearchParams(opt.form).toString(); - } else if (opt.body !== undefined) { - headers['Content-Type'] = 'application/json'; - reqBody = JSON.stringify(opt.body); - } - - const resp = await fetch(url, { method: opt.method || 'GET', headers, body: reqBody, redirect: 'manual' }); - let body: any; - const text = await resp.text(); - try { body = JSON.parse(text); } catch { body = text; } - return { status: resp.status, body, raw: resp }; -} - -// ─── 测试运行器 ───────────────────────────────────────────────────────────── - -let currentGroup = ''; - -function group(name: string) { - currentGroup = name; - console.log(`\n${c.bold}${c.cyan}━━ ${name} ━━${c.reset}`); -} - -async function test(name: string, fn: () => Promise<{ ok: boolean; detail?: string; warn?: boolean }>): Promise { - const t0 = performance.now(); - let status: Status = 'PASS'; - let detail: string | undefined; - try { - const r = await fn(); - if (r.warn) { - status = 'WARN'; - } else { - status = r.ok ? 'PASS' : 'FAIL'; - } - detail = r.detail; - } catch (e: any) { - status = 'FAIL'; - detail = e.message || String(e); - } - const ms = performance.now() - t0; - results.push({ group: currentGroup, name, status, detail, ms }); - const icon = { PASS: `${c.green}✔`, FAIL: `${c.red}✘`, WARN: `${c.yellow}⚠`, SKIP: `${c.gray}○` }[status]; - const time = `${c.dim}${ms.toFixed(0)}ms${c.reset}`; - const det = detail ? ` ${c.dim}${detail}${c.reset}` : ''; - console.log(` ${icon} ${c.reset}${name} ${time}${det}`); -} - -function skip(name: string, reason: string) { - results.push({ group: currentGroup, name, status: 'SKIP', detail: reason, ms: 0 }); - console.log(` ${c.gray}○ ${name} ${c.dim}${reason}${c.reset}`); -} - -// ─── 结构验证辅助 ────────────────────────────────────────────────────────── - -/** 检查对象是否包含指定的所有键,返回缺失键列表 */ -function hasKeys(obj: any, keys: string[]): string[] { - if (!obj || typeof obj !== 'object') return ['(不是对象)']; - return keys.filter(k => !(k in obj)); -} - -/** 验证 Bitwarden 列表格式 { data: [...], object: "list" } */ -function expectList(body: any, objectName = 'list'): { ok: boolean; detail?: string } { - const missing = hasKeys(body, ['data', 'object']); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - if (body.object !== objectName) return { ok: false, detail: `object="${body.object}" 期望="${objectName}"` }; - if (!Array.isArray(body.data)) return { ok: false, detail: 'data 不是数组' }; - return { ok: true }; -} - -// ─── 客户端期望的关键响应字段清单 ────────────────────────────────────────── - -// Profile:全平台客户端都会读取这些字段 -const PROFILE_KEYS = [ - 'id', 'name', 'email', 'emailVerified', 'premium', 'key', 'privateKey', - 'securityStamp', 'organizations', 'providers', 'providerOrganizations', - 'twoFactorEnabled', 'forcePasswordReset', 'culture', 'object', 'creationDate', -]; - -// Cipher:密码项响应的完整字段 -const CIPHER_KEYS = [ - 'id', 'type', 'name', 'favorite', 'reprompt', 'edit', 'viewPassword', - 'creationDate', 'revisionDate', 'object', 'collectionIds', 'organizationId', - 'permissions', 'deletedDate', -]; - -const FOLDER_KEYS = ['id', 'name', 'revisionDate', 'object']; - -// Sync:全量同步的顶级字段 -const SYNC_KEYS = [ - 'profile', 'folders', 'collections', 'ciphers', 'domains', - 'policies', 'sends', 'object', 'UserDecryptionOptions', 'userDecryption', -]; - -// Token:登录/刷新响应的必需字段 -const TOKEN_KEYS = [ - 'access_token', 'expires_in', 'token_type', 'refresh_token', - 'Key', 'PrivateKey', 'Kdf', 'KdfIterations', 'scope', 'UserDecryptionOptions', -]; - -// ═══════════════════════════════════════════════════════════════════════════ -// 测试套件 -// ═══════════════════════════════════════════════════════════════════════════ - -// ─── 1. 服务器连通性 + Config 深度验证 ────────────────────────────────────── -// 验证服务器基础端点、Config 结构、favicon、DevTools 探针 - -async function suiteConnectivity() { - group('1 · 服务器连通性'); - - await test('GET /config 返回有效配置', async () => { - const { status, body } = await api('/config', { auth: false }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, ['version', 'environment', 'object']); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - return { ok: body.object === 'config', detail: `版本 ${body.version}` }; - }); - - await test('GET /api/config(别名路径)', async () => { - const { status, body } = await api('/api/config', { auth: false }); - return { ok: status === 200 && body?.object === 'config' }; - }); - - // Config.environment 所有 URL 字段必须指向服务器自身 - // 客户端用这些 URL 构建后续请求地址 - await test('Config.environment URL 一致性', async () => { - const { body } = await api('/config', { auth: false }); - const env = body?.environment; - if (!env) return { ok: false, detail: 'environment 缺失' }; - const checks = [ - env.vault && env.vault.startsWith('http'), - env.api && env.api.includes('/api'), - env.identity && env.identity.includes('/identity'), - env.notifications && env.notifications.includes('/notifications'), - ]; - return { ok: checks.every(Boolean), detail: `vault=${env.vault}` }; - }); - - // featureStates 字段存在(客户端读取 feature flags) - await test('Config.featureStates 存在', async () => { - const { body } = await api('/config', { auth: false }); - return { ok: body?.featureStates && typeof body.featureStates === 'object' }; - }); - - await test('GET /api/version 返回版本字符串', async () => { - const { status, body } = await api('/api/version', { auth: false }); - return { ok: status === 200 && typeof body === 'string' && body.length > 0, detail: body }; - }); - - await test('GET /favicon.ico 返回 SVG 图标', async () => { - const resp = await fetch(`${BASE}/favicon.ico`); - const ct = resp.headers.get('content-type') || ''; - const text = await resp.text(); - return { ok: resp.status === 200 && ct.includes('svg') && text.includes(' { - const resp = await fetch(`${BASE}/favicon.svg`); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - await test('GET /.well-known DevTools 探针端点', async () => { - const { status } = await api('/.well-known/appspecific/com.chrome.devtools.json', { auth: false }); - return { ok: status === 200 }; - }); -} - -// ─── 2. CORS 深度验证 ────────────────────────────────────────────────────── -// 浏览器插件(Chrome/Firefox/Safari/Edge)依赖 CORS 头 -// 缺少任何必需头都会导致插件请求被浏览器拦截 - -async function suiteCors() { - group('2 · CORS 深度验证(浏览器插件必需)'); - - await test('OPTIONS / 返回 204 + CORS 头', async () => { - const resp = await fetch(`${BASE}/`, { - method: 'OPTIONS', - headers: { Origin: BASE }, - }); - const acao = resp.headers.get('access-control-allow-origin'); - return { ok: resp.status === 204 && acao === BASE }; - }); - - // 浏览器插件请求 /identity/connect/token 前会发 OPTIONS 预检 - await test('OPTIONS /identity/connect/token CORS 预检', async () => { - const resp = await fetch(`${BASE}/identity/connect/token`, { method: 'OPTIONS' }); - return { ok: resp.status === 204 }; - }); - - // 浏览器插件请求 /api/sync 前也会预检 - await test('OPTIONS /api/sync CORS 预检', async () => { - const resp = await fetch(`${BASE}/api/sync`, { method: 'OPTIONS' }); - return { ok: resp.status === 204 }; - }); - - // Access-Control-Allow-Headers 必须包含这些头 - // Bitwarden 客户端会发送 Device-Type、Bitwarden-Client-Name 等自定义头 - await test('CORS Allow-Headers 包含全部必需头', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); - const ah = (resp.headers.get('access-control-allow-headers') || '').toLowerCase(); - const required = ['authorization', 'content-type', 'accept', 'device-type', - 'bitwarden-client-name', 'bitwarden-client-version']; - const missing = required.filter(h => !ah.includes(h)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : '全部包含' }; - }); - - // Allow-Methods 必须包含所有 HTTP 方法 - await test('CORS Allow-Methods 包含 GET/POST/PUT/DELETE', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); - const am = (resp.headers.get('access-control-allow-methods') || '').toUpperCase(); - const required = ['GET', 'POST', 'PUT', 'DELETE']; - const missing = required.filter(m => !am.includes(m)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; - }); - - // 实际 JSON 响应也必须带 CORS 头(不只是 OPTIONS) - await test('JSON 响应包含 Access-Control-Allow-Origin(同源)', async () => { - const resp = await fetch(`${BASE}/config`, { - headers: { Origin: BASE }, - }); - const acao = resp.headers.get('access-control-allow-origin'); - return { ok: acao === BASE }; - }); -} - -// ─── 3. 注册与设置 ────────────────────────────────────────────────────────── - -async function suiteRegistration() { - group('3 · 注册与设置'); - - await test('GET /setup/status 返回设置状态', async () => { - const { status, body } = await api('/setup/status', { auth: false }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - return { ok: 'registered' in body && 'disabled' in body, detail: `已注册=${body.registered}` }; - }); - - // 用默认 KDF 参数计算密码哈希(注册时用默认参数) - const defaultIter = 600000; - masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, 0, defaultIter); - userEncKey = generateFakeEncKey(); - - await test('POST /api/accounts/register(单用户注册)', async () => { - const { status, body } = await api('/api/accounts/register', { - method: 'POST', auth: false, - body: { - email: EMAIL, name: EMAIL.split('@')[0], - masterPasswordHash, key: userEncKey, - kdf: 0, kdfIterations: defaultIter, kdfMemory: null, kdfParallelism: null, - keys: { publicKey: 'selfcheck-pubkey-placeholder', encryptedPrivateKey: 'selfcheck-privkey-placeholder' }, - }, - }); - if (status === 200) { isNewRegistration = true; return { ok: true, detail: '✓ 新用户创建成功' }; } - if (status === 403) { return { ok: true, detail: '已有用户注册(正常)' }; } - return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - }); - - await test('POST /api/accounts/register 重复注册 → 403', async () => { - const { status } = await api('/api/accounts/register', { - method: 'POST', auth: false, - body: { - email: 'duplicate@test.com', masterPasswordHash: 'x', key: 'x', - kdf: 0, kdfIterations: 600000, - keys: { publicKey: 'x', encryptedPrivateKey: 'x' }, - }, - }); - return { ok: status === 403, detail: `状态码=${status}` }; - }); -} - -// ─── 4. 认证 ── 多客户端 + JWT Claims + 边界条件 ─────────────────────────── -// 覆盖所有平台的登录行为差异 - -async function suiteAuth() { - group('4 · 认证(多平台登录 + JWT 声明)'); - - // 4.1 Prelogin - await test('POST /identity/accounts/prelogin 返回 KDF 参数', async () => { - const { status, body } = await api('/identity/accounts/prelogin', { - method: 'POST', auth: false, body: { email: EMAIL }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - // 用服务器返回的真实 KDF 参数重新计算密码哈希 - masterPasswordHash = computePasswordHash(PASSWORD, EMAIL, body.kdf, body.kdfIterations); - return { ok: true, detail: `kdf=${body.kdf} 迭代=${body.kdfIterations}` }; - }); - - // 防枚举:不存在的用户也返回默认参数 - await test('Prelogin 不存在的用户 → 返回默认参数(防枚举)', async () => { - const { status, body } = await api('/identity/accounts/prelogin', { - method: 'POST', auth: false, body: { email: 'nobody-exists@test.com' }, - }); - return { ok: status === 200 && body.kdf === 0 && body.kdfIterations === 600000 }; - }); - - // 4.2 密码登录(web client_id) - await test('密码登录 client_id=web', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - const missing = hasKeys(body, TOKEN_KEYS); - if (missing.length) return { ok: false, detail: `缺少字段: ${missing.join(', ')}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - userEncKey = body.Key; - return { ok: true, detail: `有效期=${body.expires_in}s` }; - }); - - // 4.3 不同 client_id 登录(模拟各平台客户端) - // 浏览器插件用 browser,桌面端用 desktop,移动端用 mobile,CLI 用 cli - for (const cid of ['browser', 'desktop', 'mobile', 'cli']) { - await test(`密码登录 client_id=${cid}(${ - { browser: '浏览器插件', desktop: '桌面端', mobile: '移动端', cli: 'CLI' }[cid] - })`, async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: cid, - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - // 更新令牌到最新的 - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: !!body.access_token && !!body.Key }; - }); - } - - // 4.4 带设备头的登录(Android/iOS 会发送 deviceType、deviceName、deviceIdentifier) - await test('带设备头登录(Android 设备参数)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'mobile', - deviceType: '0', deviceName: 'Android', deviceIdentifier: 'selfcheck-device-id', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true }; - }); - - // 4.5 JSON 格式登录(部分第三方客户端用 JSON 而非 form-urlencoded) - await test('JSON 格式登录(非 form-urlencoded)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - body: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true }; - }); - - // 4.6 错误密码 - await test('错误密码 → 400 invalid_grant', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, - }); - return { ok: status === 400 && body?.error === 'invalid_grant' }; - }); - - // 4.7 缺少字段 - await test('缺少 grant_type → 400', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, form: { username: EMAIL, password: 'x' }, - }); - return { ok: status === 400 }; - }); - - // 4.8 不支持的 grant_type - await test('grant_type=client_credentials → 400', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'client_credentials', client_id: 'x', client_secret: 'x' }, - }); - return { ok: status === 400 }; - }); - - // 4.9 JWT 内部声明验证 - // 移动端(Android/iOS)会解码 JWT 检查这些字段,缺失会导致认证失败 - await test('JWT payload 包含 email_verified=true(移动端必需)', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - return { ok: payload.email_verified === true, detail: `email_verified=${payload.email_verified}` }; - }); - - await test('JWT payload 包含 amr=["Application"](移动端必需)', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - return { ok: Array.isArray(payload.amr) && payload.amr.includes('Application') }; - }); - - await test('JWT payload 包含 premium=true', async () => { - const payload = decodeJwtPayload(accessToken); - return { ok: payload?.premium === true }; - }); - - await test('JWT payload 包含 sub / email / sstamp / iss', async () => { - const payload = decodeJwtPayload(accessToken); - if (!payload) return { ok: false, detail: 'JWT 解码失败' }; - const missing = ['sub', 'email', 'sstamp', 'iss'].filter(k => !(k in payload)); - return { ok: missing.length === 0, detail: missing.length ? `缺少: ${missing.join(', ')}` : undefined }; - }); - - // 4.10 Token 响应的 UserDecryptionOptions 深度验证 - await test('Token.UserDecryptionOptions 嵌套结构完整', async () => { - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: masterPasswordHash }, - }); - accessToken = body.access_token; - refreshToken = body.refresh_token; - const udo = body?.UserDecryptionOptions; - if (!udo) return { ok: false, detail: 'UDO 缺失' }; - const mpu = udo.MasterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; - const checks = [ - udo.HasMasterPassword === true, - mpu.Salt === EMAIL, - mpu.MasterKeyWrappedUserKey != null, - mpu.Kdf?.KdfType === 0, - mpu.Kdf?.Iterations === 600000 || mpu.Kdf?.Iterations > 0, - ]; - const failed = checks.filter(c => !c).length; - return { ok: failed === 0, detail: failed ? `${failed} 项检查失败` : `Salt=${mpu.Salt}` }; - }); -} - -// ─── 5. 令牌刷新完整性 ───────────────────────────────────────────────────── -// 令牌刷新是客户端后台自动行为,响应结构必须与登录一致 - -async function suiteRefresh() { - group('5 · 令牌刷新完整性'); - - if (!refreshToken) { skip('全部刷新测试', '无刷新令牌'); return; } - - // 保存旧 refresh_token 用于后续的复用测试 - const oldRefreshToken = refreshToken; - - await test('刷新令牌 → 返回全部字段', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, TOKEN_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true, detail: '令牌已轮换' }; - }); - - // 刷新响应必须包含 UserDecryptionOptions(Android 空 vault 解锁依赖此) - await test('刷新响应包含 UserDecryptionOptions', async () => { - // 用新的 refresh_token 再刷新一次 - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - accessToken = body.access_token; - refreshToken = body.refresh_token; - const udo = body?.UserDecryptionOptions; - return { ok: !!udo && udo.HasMasterPassword === true && !!udo.MasterPasswordUnlock }; - }); - - // 刷新响应必须包含 Key 和 PrivateKey(桌面端重建加密上下文需要) - await test('刷新响应包含 Key 和 PrivateKey', async () => { - // 通过上一次测试已更新了 accessToken,直接检查最近的响应 - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: refreshToken }, - }); - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: body?.Key != null && typeof body.Key === 'string' && body.Key.length > 0 }; - }); - - // 安全性:旧的 refresh_token 不可复用(令牌轮换机制) - await test('旧 refresh_token 不可复用(令牌轮换安全性)', async () => { - const { status } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'refresh_token', refresh_token: oldRefreshToken }, - }); - return { ok: status === 400 || status === 401, detail: `状态码=${status}` }; - }); -} - -// ─── 6. 空保管库回归测试 ─────────────────────────────────────────────────── -// 【关键场景】用户报告的 bug:刚注册、没有任何密码项,锁定后解锁报错。 -// -// 复现路径:注册 → 登录 → sync(空数据)→ 锁定(前端丢弃密钥)→ 重新登录 -// 本套件模拟这个完整流程,验证空 vault 状态下所有核心端点正常工作。 -// -// 客户端锁定/解锁的本质: -// 锁定 = 前端丢弃内存中的 masterKey 和 encKey -// 解锁 = 用密码重新派生 masterKey → 调用 /identity/connect/token → 获取 Key → 解密 -// 所以 "解锁失败" 的根因通常是 Token 响应中 Key 为空或 UDO 结构不完整。 - -async function suiteEmptyVault() { - group('6 · 空保管库回归测试(锁定/解锁 bug 场景)'); - - if (!accessToken) { skip('全部空保管库测试', '未获取到访问令牌'); return; } - - // 6.1 空 vault sync — 最核心的测试 - await test('空 vault GET /api/sync 结构完整', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, SYNC_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - // 即使没有数据,数组字段也必须存在且为数组(不能是 null/undefined) - const arrays = ['folders', 'collections', 'ciphers', 'policies', 'sends']; - const nullArrays = arrays.filter(k => !Array.isArray(body[k])); - if (nullArrays.length) return { ok: false, detail: `非数组字段: ${nullArrays.join(', ')}` }; - return { ok: body.object === 'sync' }; - }); - - // 6.2 空 vault 下 ciphers 列表 - await test('空 vault GET /api/ciphers → 空列表', async () => { - const { status, body } = await api('/api/ciphers'); - const r = expectList(body); - if (!r.ok) return r; - // 注意:可能上次测试残留了数据,这里只验证格式正确 - return { ok: status === 200, detail: `数量=${body.data.length}` }; - }); - - // 6.3 空 vault 下 folders 列表 - await test('空 vault GET /api/folders → 空列表', async () => { - const { status, body } = await api('/api/folders'); - const r = expectList(body); - return { ok: status === 200 && r.ok, detail: `数量=${body.data.length}` }; - }); - - // 6.4 空 vault 下 revision-date 仍然有效 - await test('空 vault revision-date 有效(>0)', async () => { - const { status, body } = await api('/api/accounts/revision-date'); - return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; - }); - - // 6.5 Sync.UserDecryptionOptions 深度验证(PascalCase — 桌面端/浏览器插件) - await test('Sync.UserDecryptionOptions 嵌套结构(桌面端/浏览器插件)', async () => { - const { body } = await api('/api/sync'); - const udo = body?.UserDecryptionOptions; - if (!udo) return { ok: false, detail: 'UDO 缺失' }; - const mpu = udo.MasterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'MasterPasswordUnlock 缺失' }; - // Salt 必须等于用户邮箱,否则客户端 KDF 计算会出错 - if (mpu.Salt !== EMAIL) return { ok: false, detail: `Salt="${mpu.Salt}" 期望="${EMAIL}"` }; - // MasterKeyWrappedUserKey 不能为 null(这是解锁的关键数据) - if (!mpu.MasterKeyWrappedUserKey) return { ok: false, detail: 'MasterKeyWrappedUserKey 为空' }; - // Kdf 结构 - if (!mpu.Kdf) return { ok: false, detail: 'Kdf 缺失' }; - if (typeof mpu.Kdf.KdfType !== 'number') return { ok: false, detail: 'Kdf.KdfType 缺失' }; - return { ok: true, detail: `KdfType=${mpu.Kdf.KdfType} Iterations=${mpu.Kdf.Iterations}` }; - }); - - // 6.6 Sync.userDecryption 深度验证(camelCase — Android 专用) - await test('Sync.userDecryption 嵌套结构(Android 客户端)', async () => { - const { body } = await api('/api/sync'); - const ud = body?.userDecryption; - if (!ud) return { ok: false, detail: 'userDecryption 缺失' }; - const mpu = ud.masterPasswordUnlock; - if (!mpu) return { ok: false, detail: 'masterPasswordUnlock 缺失' }; - if (mpu.salt !== EMAIL) return { ok: false, detail: `salt="${mpu.salt}" 期望="${EMAIL}"` }; - if (!mpu.masterKeyWrappedUserKey) return { ok: false, detail: 'masterKeyWrappedUserKey 为空' }; - if (!mpu.kdf) return { ok: false, detail: 'kdf 缺失' }; - if (typeof mpu.kdf.kdfType !== 'number') return { ok: false, detail: 'kdf.kdfType 缺失' }; - return { ok: true }; - }); - - // 6.7 Sync.domains 结构(不能为 null) - await test('Sync.domains 结构完整', async () => { - const { body } = await api('/api/sync'); - const d = body?.domains; - if (!d) return { ok: false, detail: 'domains 缺失' }; - return { - ok: d.object === 'domains' - && Array.isArray(d.equivalentDomains) - && Array.isArray(d.globalEquivalentDomains), - }; - }); - - // 6.8 模拟锁定后解锁(本质是重新登录 → 获取完整 Token 响应) - await test('模拟解锁:重新登录获取 Key(锁定/解锁核心)', async () => { - const { status, body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { - grant_type: 'password', username: EMAIL, password: masterPasswordHash, - scope: 'api offline_access', client_id: 'web', - }, - }); - if (status !== 200) return { ok: false, detail: `登录失败 状态码=${status}` }; - // Key 必须非空且格式有效(这是解锁失败的常见根因) - if (!body.Key || typeof body.Key !== 'string' || body.Key.length < 10) { - return { ok: false, detail: `Key 无效: "${body.Key}"` }; - } - accessToken = body.access_token; - refreshToken = body.refresh_token; - return { ok: true, detail: `Key 长度=${body.Key.length}` }; - }); - - // 6.9 解锁后 sync 正常 - await test('解锁后 sync 正常', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const ok = body?.object === 'sync' && body?.profile?.email === EMAIL; - return { ok }; - }); - - // 6.10 Profile 的 key 字段非空(解锁时用于初始化加密上下文) - await test('Profile.key 非空(加密上下文初始化依赖)', async () => { - const { body } = await api('/api/accounts/profile'); - return { ok: body?.key != null && typeof body.key === 'string' && body.key.length > 10 }; - }); -} - -// ─── 7. 账户端点 ──────────────────────────────────────────────────────────── - -async function suiteAccounts() { - group('7 · 账户端点'); - - if (!accessToken) { skip('全部账户测试', '未获取到访问令牌'); return; } - - await test('GET /api/accounts/profile 获取用户资料', async () => { - const { status, body } = await api('/api/accounts/profile'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, PROFILE_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - userId = body.id; - return { ok: body.object === 'profile' && body.email === EMAIL, detail: `id=${userId}` }; - }); - - // Profile 详细字段验证 - await test('Profile 字段类型正确', async () => { - const { body } = await api('/api/accounts/profile'); - const checks: [string, boolean][] = [ - ['emailVerified=true', body.emailVerified === true], - ['premium=true', body.premium === true], - ['twoFactorEnabled=bool', typeof body.twoFactorEnabled === 'boolean'], - ['forcePasswordReset=false', body.forcePasswordReset === false], - ['organizations=array', Array.isArray(body.organizations)], - ['providers=array', Array.isArray(body.providers)], - ['providerOrganizations=array', Array.isArray(body.providerOrganizations)], - ['culture=string', typeof body.culture === 'string'], - ]; - const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); - return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; - }); - - await test('PUT /api/accounts/profile 更新用户资料', async () => { - const { status, body } = await api('/api/accounts/profile', { - method: 'PUT', body: { name: 'SelfCheck Updated', masterPasswordHint: null }, - }); - return { ok: status === 200 && body?.object === 'profile' }; - }); - - await test('POST /api/accounts/keys 更新密钥', async () => { - const current = await api('/api/accounts/profile'); - if (current.status !== 200 || !current.body?.key || !current.body?.privateKey) { - return { ok: false, detail: '无法读取当前 key/privateKey' }; - } - const { status, body } = await api('/api/accounts/keys', { - method: 'POST', - // Non-destructive roundtrip: submit current encrypted keys as-is. - body: { key: current.body.key, encryptedPrivateKey: current.body.privateKey }, - }); - return { ok: status === 200 && body?.object === 'profile' }; - }); - - await test('GET /api/accounts/revision-date 时间戳', async () => { - const { status, body } = await api('/api/accounts/revision-date'); - return { ok: status === 200 && typeof body === 'number' && body > 0, detail: `时间戳=${body}` }; - }); - - await test('POST /api/accounts/verify-password 正确密码 → 200', async () => { - const { status } = await api('/api/accounts/verify-password', { - method: 'POST', body: { masterPasswordHash }, - }); - return { ok: status === 200 }; - }); - - await test('POST /api/accounts/verify-password 错误密码 → 400', async () => { - const { status } = await api('/api/accounts/verify-password', { - method: 'POST', body: { masterPasswordHash: 'wrong-hash-value' }, - }); - return { ok: status === 400 }; - }); -} - -// ─── 8. 同步深度验证 ─────────────────────────────────────────────────────── - -async function suiteSync() { - group('8 · 同步深度验证'); - - if (!accessToken) { skip('全部同步测试', '未获取到访问令牌'); return; } - - await test('GET /api/sync 完整同步', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, SYNC_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - const pMissing = hasKeys(body.profile, PROFILE_KEYS); - if (pMissing.length) return { ok: false, detail: `profile 缺少: ${pMissing.join(', ')}` }; - return { ok: body.object === 'sync' }; - }); - - // Sync.profile 与独立 Profile 一致性 - await test('Sync.profile 与 GET /api/accounts/profile 一致', async () => { - const [sync, profile] = await Promise.all([api('/api/sync'), api('/api/accounts/profile')]); - const sp = sync.body?.profile; - const pp = profile.body; - return { ok: sp?.id === pp?.id && sp?.email === pp?.email && sp?.key === pp?.key }; - }); -} - -// ─── 9. 文件夹 CRUD ───────────────────────────────────────────────────────── - -async function suiteFolders() { - group('9 · 文件夹'); - - if (!accessToken) { skip('全部文件夹测试', '未获取到访问令牌'); return; } - - await test('POST /api/folders 创建', async () => { - const { status, body } = await api('/api/folders', { - method: 'POST', body: { name: '2.自查测试文件夹==' }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const missing = hasKeys(body, FOLDER_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - testFolderId = body.id; - return { ok: body.object === 'folder', detail: `id=${testFolderId}` }; - }); - - await test('GET /api/folders 列表', async () => { - const { status, body } = await api('/api/folders'); - const r = expectList(body); - if (!r.ok) return r; - return { ok: body.data.length >= 1, detail: `数量=${body.data.length}` }; - }); - - await test('GET /api/folders/:id 单个', async () => { - if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; - const { status, body } = await api(`/api/folders/${testFolderId}`); - return { ok: status === 200 && body?.id === testFolderId }; - }); - - await test('PUT /api/folders/:id 更新', async () => { - if (!testFolderId) return { ok: false, detail: '无可用文件夹' }; - const { status, body } = await api(`/api/folders/${testFolderId}`, { - method: 'PUT', body: { name: '2.更新后文件夹==' }, - }); - return { ok: status === 200 && body?.object === 'folder' }; - }); -} - -// ─── 10. 密码项 CRUD + 边界条件 ───────────────────────────────────────────── - -async function suiteCiphers() { - group('10 · 密码项(Ciphers)'); - - if (!accessToken) { skip('全部密码项测试', '未获取到访问令牌'); return; } - - // 记录创建前的 revision-date,用于后面验证递增 - let revDateBefore = 0; - { - const { body } = await api('/api/accounts/revision-date'); - if (typeof body === 'number') revDateBefore = body; - } - - // --- 创建:四种类型 --- - - await test('POST /api/ciphers 创建 Login 类型', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 1, name: '2.测试登录项==', notes: '2.备注内容==', - folderId: testFolderId || null, favorite: true, reprompt: 0, - login: { - username: '2.用户名==', password: '2.密码==', - uris: [{ uri: '2.https://example.com==', match: null }], totp: null, - }, - fields: [{ name: '2.自定义字段==', value: '2.值==', type: 0, linkedId: null }], - passwordHistory: [{ password: '2.旧密码==', lastUsedDate: new Date().toISOString() }], - }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - const missing = hasKeys(body, CIPHER_KEYS); - if (missing.length) return { ok: false, detail: `缺少: ${missing.join(', ')}` }; - testCipherId = body.id; - allCreatedCipherIds.push(body.id); - return { ok: body.object === 'cipher' && body.type === 1, detail: `id=${testCipherId}` }; - }); - - await test('POST /api/ciphers 创建 SecureNote', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', body: { type: 2, name: '2.安全笔记==', secureNote: { type: 0 }, reprompt: 0 }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - testCipher2Id = body.id; - allCreatedCipherIds.push(body.id); - return { ok: body.type === 2, detail: `id=${testCipher2Id}` }; - }); - - await test('POST /api/ciphers 创建 Card', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 3, name: '2.银行卡==', reprompt: 0, - card: { cardholderName: '2.持卡人==', number: '2.卡号==', brand: '2.Visa==', - expMonth: '2.01==', expYear: '2.2030==', code: '2.123==' }, - }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.type === 3 }; - }); - - await test('POST /api/ciphers 创建 Identity', async () => { - const { status, body } = await api('/api/ciphers', { - method: 'POST', - body: { - type: 4, name: '2.身份信息==', reprompt: 0, - identity: { firstName: '2.名==', lastName: '2.姓==', email: '2.邮箱==' }, - }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.type === 4 }; - }); - - // 部分客户端用 { cipher: {...} } 嵌套格式 - await test('POST /api/ciphers/create 嵌套格式', async () => { - const { status, body } = await api('/api/ciphers/create', { - method: 'POST', - body: { cipher: { type: 2, name: '2.嵌套创建==', secureNote: { type: 0 }, reprompt: 0 } }, - }); - if (body?.id) allCreatedCipherIds.push(body.id); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // --- 响应字段深度验证 --- - - await test('Cipher 响应字段完整性', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { body } = await api(`/api/ciphers/${testCipherId}`); - const checks: [string, boolean][] = [ - ['organizationId=null', body.organizationId === null], - ['edit=true', body.edit === true], - ['viewPassword=true', body.viewPassword === true], - ['collectionIds=[]', Array.isArray(body.collectionIds) && body.collectionIds.length === 0], - ['permissions.delete=true', body.permissions?.delete === true], - ['permissions.restore=true', body.permissions?.restore === true], - ['deletedDate=null', body.deletedDate === null], - ]; - const failed = checks.filter(([, ok]) => !ok).map(([name]) => name); - return { ok: failed.length === 0, detail: failed.length ? `失败: ${failed.join(', ')}` : undefined }; - }); - - // --- 读取 --- - - await test('GET /api/ciphers 列表', async () => { - const { status, body } = await api('/api/ciphers'); - const r = expectList(body); - if (!r.ok) return r; - return { ok: body.data.length >= 4, detail: `数量=${body.data.length}` }; - }); - - await test('GET /api/ciphers/:id 单个', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`); - return { ok: status === 200 && body?.id === testCipherId }; - }); - - await test('GET /api/ciphers/:id/details 详情', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/details`); - return { ok: status === 200 && body?.id === testCipherId }; - }); - - // --- 更新 --- - - await test('PUT /api/ciphers/:id 更新', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { - method: 'PUT', - body: { type: 1, name: '2.已更新==', reprompt: 0, - login: { username: '2.新用户名==', password: '2.新密码==', uris: [] } }, - }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // POST 方式更新(部分 Android 客户端行为) - await test('POST /api/ciphers/:id 更新(POST 别名)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { - method: 'POST', - body: { type: 1, name: '2.POST更新==', reprompt: 0, - login: { username: '2.u==', password: '2.p==', uris: [] } }, - }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - await test('PUT /api/ciphers/:id/partial 部分更新', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/partial`, { - method: 'PUT', body: { favorite: false, folderId: null }, - }); - return { ok: status === 200 && body?.favorite === false }; - }); - - await test('POST /api/ciphers/:id/share(单用户 stub)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/share`, { method: 'POST', body: {} }); - return { ok: status === 200 && body?.object === 'cipher' }; - }); - - // --- revision-date 递增验证 --- - - await test('写操作后 revision-date 递增', async () => { - const { body } = await api('/api/accounts/revision-date'); - if (typeof body !== 'number') return { ok: false, detail: '返回非数字' }; - return { ok: body >= revDateBefore, detail: `前=${revDateBefore} 后=${body}` }; - }); - - // --- 软删除、恢复、永久删除 --- - - await test('DELETE /api/ciphers/:id 软删除', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}`, { method: 'DELETE' }); - return { ok: status === 200 && body?.deletedDate != null }; - }); - - await test('PUT /api/ciphers/:id/restore 恢复', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/restore`, { method: 'PUT' }); - return { ok: status === 200 && body?.deletedDate === null }; - }); - - await test('PUT /api/ciphers/:id/delete 软删除(别名)', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status, body } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'PUT' }); - return { ok: status === 200 && body?.deletedDate != null }; - }); - - // 验证 deleted 过滤功能 - await test('GET /api/ciphers 默认不含已删除项', async () => { - const { body } = await api('/api/ciphers'); - const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); - return { ok: !hasDeleted, detail: hasDeleted ? '包含已删除项' : undefined }; - }); - - await test('GET /api/ciphers?deleted=true 包含已删除项', async () => { - const { body } = await api('/api/ciphers?deleted=true'); - // 至少有一个被软删除的项(testCipher2Id) - const hasDeleted = body?.data?.some((c: any) => c.deletedDate != null); - return { ok: body?.data?.length > 0 && hasDeleted, detail: `数量=${body?.data?.length}` }; - }); - - await test('DELETE /api/ciphers/:id/delete 永久删除', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status } = await api(`/api/ciphers/${testCipher2Id}/delete`, { method: 'DELETE' }); - return { ok: status === 204 || status === 200 }; - }); - - await test('永久删除后 → 404', async () => { - if (!testCipher2Id) return { ok: false, detail: '无可用密码项' }; - const { status } = await api(`/api/ciphers/${testCipher2Id}`); - return { ok: status === 404 }; - }); - - // --- 批量操作 --- - - await test('POST /api/ciphers/move 批量移动', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status } = await api('/api/ciphers/move', { - method: 'POST', body: { ids: [testCipherId], folderId: testFolderId || null }, - }); - return { ok: status === 204 || status === 200 }; - }); - - // PUT 也应该支持(部分桌面端行为) - await test('PUT /api/ciphers/move 批量移动(PUT 别名)', async () => { - if (!testCipherId) return { ok: false, detail: '无可用密码项' }; - const { status } = await api('/api/ciphers/move', { - method: 'PUT', body: { ids: [testCipherId], folderId: null }, - }); - return { ok: status === 204 || status === 200 }; - }); - - await test('POST /api/ciphers/import 批量导入', async () => { - const { status, body } = await api('/api/ciphers/import', { - method: 'POST', - body: { - ciphers: [{ type: 1, name: '2.导入项==', login: { username: '2.u==', password: '2.p==' }, reprompt: 0 }], - folders: [{ name: '2.导入文件夹==' }], - folderRelationships: [{ key: 0, value: 0 }], - }, - }); - // Track imported ciphers/folders for cleanup - if (body?.ciphers) for (const c of body.ciphers) { if (c?.id) allCreatedCipherIds.push(c.id); } - if (body?.folders) for (const f of body.folders) { if (f?.id) allCreatedFolderIds.push(f.id); } - return { ok: status === 200, detail: `状态码=${status}` }; - }); -} - -// ─── 11. 附件 ─────────────────────────────────────────────────────────────── - -async function suiteAttachments() { - group('11 · 附件'); - - if (!accessToken || !testCipherId) { skip('全部附件测试', '无可用令牌或密码项'); return; } - - // v2 端点(新版客户端标准流程) - await test('POST /api/ciphers/:id/attachment/v2 创建元数据', async () => { - const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/v2`, { - method: 'POST', body: { fileName: '2.测试文件.txt==', key: '2.附件密钥==', fileSize: 42 }, - }); - if (status !== 200) return { ok: false, detail: `状态码=${status} ${JSON.stringify(body)}` }; - testAttachmentId = body.attachmentId; - return { ok: !!testAttachmentId && body.object === 'attachment-fileUpload' && !!body.url, - detail: `id=${testAttachmentId}` }; - }); - - await test('POST /api/ciphers/:id/attachment/:aid 上传文件', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const formData = new FormData(); - formData.append('data', new Blob([new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])]), 'test.bin'); - const resp = await fetch(`${BASE}/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { - method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` }, body: formData, - }); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // 上传后验证附件出现在 cipher 的 attachments 数组中 - await test('上传后 cipher.attachments 非空(Android 依赖)', async () => { - const { body } = await api(`/api/ciphers/${testCipherId}`); - const atts = body?.attachments; - if (!Array.isArray(atts) || atts.length === 0) return { ok: false, detail: 'attachments 为空' }; - const att = atts[0]; - // Android 要求 url 非 null,size 为数字 - const checks = [ - typeof att.url === 'string' && att.url.length > 0, - typeof att.size === 'number', - typeof att.fileName === 'string', - ]; - const ok = checks.every(Boolean); - return { ok, detail: ok ? `url=${att.url} size=${att.size}` : 'url/size 格式不符' }; - }); - - // 获取下载链接 - await test('GET /api/ciphers/:id/attachment/:aid 下载链接', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const { status, body } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - const ok = body.object === 'attachment' && typeof body.url === 'string' && body.url.includes('token='); - if (ok) { - const u = new URL(body.url); - downloadToken = u.searchParams.get('token') || ''; - } - return { ok }; - }); - - // 公开下载 - await test('GET /api/attachments/:cid/:aid?token= 公开下载', async () => { - if (!downloadToken) return { ok: false, detail: '无下载令牌' }; - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=${downloadToken}`); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // 安全性:无 token 的下载应被拒绝 - await test('公开下载无 token → 401', async () => { - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}`); - return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; - }); - - // 安全性:无效 token 的下载应被拒绝 - await test('公开下载无效 token → 401', async () => { - const resp = await fetch(`${BASE}/api/attachments/${testCipherId}/${testAttachmentId}?token=invalid-garbage`); - return { ok: resp.status === 401, detail: `状态码=${resp.status}` }; - }); - - // 旧版附件端点(旧版桌面客户端用这个路径) - await test('POST /api/ciphers/:id/attachment 旧版端点', async () => { - const { status } = await api(`/api/ciphers/${testCipherId}/attachment`, { - method: 'POST', body: { fileName: '2.旧版附件==', key: '2.key==', fileSize: 10 }, - }); - // 路由器已路由到同一 handler,应返回 200 - return { ok: status === 200, detail: `状态码=${status}` }; - }); - - // 删除附件 - await test('DELETE /api/ciphers/:id/attachment/:aid 删除', async () => { - if (!testAttachmentId) return { ok: false, detail: '未创建附件' }; - const { status } = await api(`/api/ciphers/${testCipherId}/attachment/${testAttachmentId}`, { - method: 'DELETE', - }); - return { ok: status === 200, detail: `状态码=${status}` }; - }); -} - -// ─── 12. Stub 端点 + 通知 + 子路径 ───────────────────────────────────────── -// 这些端点没有完整实现,但客户端会请求它们 -// 必须返回正确格式的空数据,否则客户端报错 - -async function suiteStubs() { - group('12 · Stub 端点(客户端兼容性)'); - - if (!accessToken) { skip('全部 Stub 测试', '未获取到访问令牌'); return; } - - const stubs: [string, string, string][] = [ - ['GET', '/api/collections', 'Collections(集合)'], - ['GET', '/api/organizations', 'Organizations(组织)'], - ['GET', '/api/sends', 'Sends(安全发送)'], - ['GET', '/api/policies', 'Policies(策略)'], - ['GET', '/api/auth-requests', 'Auth Requests(认证请求)'], - ['GET', '/api/devices', 'Devices(设备)'], - ]; - - for (const [method, path, label] of stubs) { - await test(`${method} ${path} → 空列表 stub(${label})`, async () => { - const { status, body } = await api(path, { method }); - const r = expectList(body); - return { ok: status === 200 && r.ok && body.data.length === 0, detail: r.detail ? r.detail : 'stub 占位' }; - }); - } - - // Stub 子路径测试(客户端可能请求带 ID 的子路径) - const subPaths: [string, string][] = [ - ['/api/organizations/00000000-0000-0000-0000-000000000000', '组织子路径'], - ['/api/collections/00000000-0000-0000-0000-000000000000', '集合子路径'], - ['/api/sends/00000000-0000-0000-0000-000000000000', '发送子路径'], - ['/api/policies/00000000-0000-0000-0000-000000000000', '策略子路径'], - ]; - - for (const [path, label] of subPaths) { - await test(`GET ${path}(${label})→ 不崩溃`, async () => { - const { status } = await api(path); - // 200 空列表或 404 都可以接受,关键是不能 500 - return { ok: status !== 500, detail: `状态码=${status}` }; - }); - } - - // 域名设置 - await test('GET /api/settings/domains → domains 对象', async () => { - const { status, body } = await api('/api/settings/domains'); - return { - ok: status === 200 && body?.object === 'domains' - && Array.isArray(body.equivalentDomains) - && Array.isArray(body.globalEquivalentDomains), - }; - }); - - await test('PUT /api/settings/domains 更新', async () => { - const { status, body } = await api('/api/settings/domains', { - method: 'PUT', body: { equivalentDomains: [], globalEquivalentDomains: [] }, - }); - return { ok: status === 200 && body?.object === 'domains' }; - }); - - // POST 别名(旧版客户端) - await test('POST /api/settings/domains(POST 别名)', async () => { - const { status, body } = await api('/api/settings/domains', { - method: 'POST', body: { equivalentDomains: [], globalEquivalentDomains: [] }, - }); - return { ok: status === 200 && body?.object === 'domains' }; - }); - - // 通知端点 — 桌面端和浏览器插件启动时必调 - await test('GET /notifications/hub → 200', async () => { - const resp = await fetch(`${BASE}/notifications/hub`); - return { ok: resp.status === 200 }; - }); - - // POST /notifications/hub/negotiate — SignalR 协商(桌面端/浏览器插件) - // 客户端启动时会发 POST 请求进行 SignalR 握手 - await test('POST /notifications/hub/negotiate → 200(SignalR 协商)', async () => { - const resp = await fetch(`${BASE}/notifications/hub/negotiate`, { method: 'POST' }); - return { ok: resp.status === 200, detail: `状态码=${resp.status}` }; - }); - - // POST /notifications/hub — SignalR WebSocket 回退 - await test('POST /notifications/hub → 200(SignalR 回退)', async () => { - const resp = await fetch(`${BASE}/notifications/hub`, { method: 'POST' }); - return { ok: resp.status === 200 }; - }); - - // 带查询参数的通知路径 - await test('GET /notifications/hub?id=xxx → 200(长轮询)', async () => { - const resp = await fetch(`${BASE}/notifications/hub?id=some-connection-id`); - return { ok: resp.status === 200 }; - }); - - // 设备已知检查 - await test('GET /api/devices/knowndevice → "true"', async () => { - const resp = await fetch(`${BASE}/api/devices/knowndevice`); - const text = await resp.text(); - return { ok: resp.status === 200 && text.trim() === 'true' }; - }); - - // 带设备头的 knowndevice(iOS/Android 会附加这些头) - await test('GET /api/devices/knowndevice + Device 头', async () => { - const resp = await fetch(`${BASE}/api/devices/knowndevice`, { - headers: { - 'X-Device-Identifier': 'selfcheck-device', - 'X-Request-Email': Buffer.from(EMAIL).toString('base64'), - }, - }); - const text = await resp.text(); - return { ok: resp.status === 200 && (text.trim() === 'true' || text.trim() === 'false') }; - }); -} - -// ─── 13. 图标代理 ────────────────────────────────────────────────────────── - -async function suiteIcons() { - group('13 · 图标代理'); - - await test('GET /icons/google.com/icon.png', async () => { - const resp = await fetch(`${BASE}/icons/google.com/icon.png`); - return { ok: resp.status === 200 || resp.status === 204, detail: `状态码=${resp.status}` }; - }); -} - -// ─── 14. 认证守卫 ────────────────────────────────────────────────────────── - -async function suiteAuthGuard() { - group('14 · 认证守卫'); - - await test('GET /api/sync 无令牌 → 401', async () => { - const { status } = await api('/api/sync', { auth: false }); - return { ok: status === 401 }; - }); - - await test('GET /api/ciphers 无效令牌 → 401', async () => { - const { status } = await api('/api/ciphers', { - auth: false, headers: { 'Authorization': 'Bearer invalid.jwt.token' }, - }); - return { ok: status === 401 }; - }); - - await test('GET /api/accounts/profile 无令牌 → 401', async () => { - const { status } = await api('/api/accounts/profile', { auth: false }); - return { ok: status === 401 }; - }); - - await test('POST /api/ciphers 无令牌 → 401', async () => { - const { status } = await api('/api/ciphers', { - method: 'POST', auth: false, body: { type: 1, name: 'x', reprompt: 0 }, - }); - return { ok: status === 401 }; - }); -} - -// ─── 15. 被阻止端点完整验证 ──────────────────────────────────────────────── -// 单用户模式下禁止修改密码和删除账户 -// 路由器阻止了多个路径 × 多种 HTTP 方法 - -async function suiteBlocked() { - group('15 · 被阻止端点(单用户模式)'); - - if (!accessToken) { skip('全部阻止测试', '未获取到访问令牌'); return; } - - // POST 方法 - const blockedPaths = [ - '/api/accounts/password', - '/api/accounts/change-password', - '/api/accounts/set-password', - '/api/accounts/master-password', - '/api/accounts/delete', - '/api/accounts/delete-account', - '/api/accounts/delete-vault', - ]; - - for (const path of blockedPaths) { - await test(`POST ${path} → 501`, async () => { - const { status } = await api(path, { method: 'POST', body: {} }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); - } - - // PUT 和 DELETE 也应该被阻止(路由器检查 POST|PUT|DELETE) - await test('PUT /api/accounts/password → 501', async () => { - const { status } = await api('/api/accounts/password', { method: 'PUT', body: {} }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); - - await test('DELETE /api/accounts/delete → 501', async () => { - const { status } = await api('/api/accounts/delete', { method: 'DELETE' }); - return { ok: status === 501, detail: `状态码=${status}` }; - }); -} - -// ─── 16. 响应结构合规性 ──────────────────────────────────────────────────── - -async function suiteResponseSchema() { - group('16 · 响应结构合规性'); - - await test('错误响应符合 Bitwarden ErrorModel 格式', async () => { - const { body } = await api('/api/ciphers/00000000-0000-0000-0000-000000000000'); - const ok = body?.ErrorModel && body.ErrorModel.Object === 'error' && typeof body.ErrorModel.Message === 'string'; - return { ok: !!ok, detail: ok ? 'ErrorModel 正确' : `内容=${JSON.stringify(body).substring(0, 100)}` }; - }); - - await test('Identity 错误响应符合 OAuth2 格式', async () => { - const { body } = await api('/identity/connect/token', { - method: 'POST', auth: false, - form: { grant_type: 'password', username: EMAIL, password: 'wrong-hash' }, - }); - return { ok: typeof body?.error === 'string' && typeof body?.error_description === 'string' }; - }); - - // 404 端点也应返回 JSON(不是纯文本) - await test('404 返回 JSON ErrorModel', async () => { - const { status, body } = await api('/api/nonexistent-endpoint-12345'); - return { - ok: status === 404 && body?.ErrorModel?.Object === 'error', - detail: typeof body === 'string' ? 'HTML/纯文本' : 'JSON', - }; - }); - - // 401 端点返回 JSON - await test('401 返回 JSON ErrorModel', async () => { - const { body } = await api('/api/sync', { auth: false }); - return { ok: body?.ErrorModel?.Object === 'error' }; - }); -} - -// ─── 17. 清理 ────────────────────────────────────────────────────────────── - -async function suiteCleanup() { - group('17 · 清理与最终验证'); - - if (!accessToken) { skip('清理', '未获取到访问令牌'); return; } - - // Permanently delete ALL test-created ciphers to avoid "[error: cannot decrypt]" leftovers. - // Collect any remaining ciphers from a sync in case some IDs were not tracked (e.g. import). - try { - const { body } = await api('/api/sync'); - if (body?.ciphers) { - for (const c of body.ciphers) { - if (c?.id && !allCreatedCipherIds.includes(c.id)) { - // Check if this cipher has a fake encrypted name (our test marker) - const n = c.name || ''; - if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { - allCreatedCipherIds.push(c.id); - } - } - } - // Also find orphan test folders from import - for (const f of (body.folders || [])) { - if (f?.id && f.id !== testFolderId && !allCreatedFolderIds.includes(f.id)) { - const n = f.name || ''; - if (n.startsWith('2.') && (n.endsWith('==') || n.endsWith('='))) { - allCreatedFolderIds.push(f.id); - } - } - } - } - } catch { /* best effort */ } - - // Delete all tracked ciphers - const cipherIds = [...new Set(allCreatedCipherIds)]; - if (cipherIds.length > 0) { - await test(`永久删除所有测试密码项 (${cipherIds.length} 个)`, async () => { - let deleted = 0; - for (const id of cipherIds) { - // Soft-delete first (required for permanent delete if not already soft-deleted) - await api(`/api/ciphers/${id}`, { method: 'DELETE' }).catch(() => {}); - const { status } = await api(`/api/ciphers/${id}/delete`, { method: 'DELETE' }); - if (status === 204 || status === 200) deleted++; - } - return { ok: deleted > 0, detail: `已删除 ${deleted}/${cipherIds.length}` }; - }); - } - - // Delete test folder - if (testFolderId) { - await test('DELETE /api/folders/:id 删除测试文件夹', async () => { - const { status } = await api(`/api/folders/${testFolderId}`, { method: 'DELETE' }); - return { ok: status === 204 || status === 200 }; - }); - - await test('文件夹删除后 → 404', async () => { - const { status } = await api(`/api/folders/${testFolderId}`); - return { ok: status === 404 }; - }); - } - - // Delete any extra test folders (from import etc.) - const extraFolderIds = [...new Set(allCreatedFolderIds)]; - if (extraFolderIds.length > 0) { - await test(`删除导入的测试文件夹 (${extraFolderIds.length} 个)`, async () => { - let deleted = 0; - for (const id of extraFolderIds) { - const { status } = await api(`/api/folders/${id}`, { method: 'DELETE' }); - if (status === 204 || status === 200) deleted++; - } - return { ok: true, detail: `已删除 ${deleted}/${extraFolderIds.length}` }; - }); - } - - await test('最终同步一致性检查', async () => { - const { status, body } = await api('/api/sync'); - if (status !== 200) return { ok: false, detail: `状态码=${status}` }; - return { ok: true, detail: `密码项=${body.ciphers?.length ?? '?'} 文件夹=${body.folders?.length ?? '?'}` }; - }); -} - -// ─── 18. 缺失端点差距分析 ────────────────────────────────────────────────── -// 列出 Bitwarden 全客户端可能调用但 NodeWarden 尚未实现的端点 -// 200=已实现, 501=明确未实现, 404=未路由, 400=端点存在但缺参数, 其他=需关注 - -async function suiteGapAnalysis() { - group('18 · 缺失端点差距分析'); - - const gaps: [string, string, string][] = [ - ['POST', '/api/two-factor/get-authenticator', 'TOTP 两步验证'], - ['POST', '/api/two-factor/get-email', '邮件两步验证'], - ['POST', '/api/two-factor/get-duo', 'Duo 两步验证'], - ['POST', '/api/two-factor/get-webauthn', 'WebAuthn 两步验证'], - ['GET', '/api/emergency-access/trusted', '紧急访问(受信任)'], - ['GET', '/api/emergency-access/granted', '紧急访问(已授权)'], - ['POST', '/api/sends', '安全发送(创建)'], - ['POST', '/api/organizations', '组织(创建)'], - ['GET', '/api/accounts/billing', '账单信息'], - ['GET', '/api/accounts/subscription', '订阅信息'], - ['GET', '/api/accounts/tax', '税务信息'], - ['POST', '/api/accounts/api-key', 'API 密钥管理'], - ['POST', '/api/accounts/rotate-api-key', '轮换 API 密钥'], - ['POST', '/api/ciphers/purge', '清空保管库'], - ['POST', '/api/ciphers/bulk-delete', '批量删除'], - ['POST', '/api/ciphers/restore', '批量恢复'], - ['POST', '/api/folders/delete', '批量删除文件夹'], - ['GET', '/api/ciphers/organization-details', '组织密码项详情'], - ['POST', '/api/accounts/email-token', '修改邮箱'], - ['POST', '/api/accounts/verify-email', '验证邮箱'], - ['PUT', '/api/devices/identifier/x/token', '推送令牌注册'], - ['DELETE', '/api/push/token', '注销推送'], - ]; - - for (const [method, path, label] of gaps) { - await test(`${method} ${path}(${label})`, async () => { - const { status } = await api(path, { method, body: method !== 'GET' && method !== 'DELETE' ? {} : undefined }); - if (status === 200) return { ok: true, detail: '✓ 已实现' }; - if (status === 400) return { ok: true, detail: '✓ 端点存在(缺参数 400)' }; - // 未实现的端点 → 标记为 WARN(黄色),不算 PASS 也不算 FAIL - if (status === 501) return { warn: true, ok: false, detail: '未实现 (501)' }; - if (status === 404) return { warn: true, ok: false, detail: '未路由 (404)' }; - if (status === 401) return { warn: true, ok: false, detail: '需认证 (401)' }; - return { warn: true, ok: false, detail: `状态码 ${status}` }; - }); - } -} - -// ─── 19. 设置页面禁用 ────────────────────────────────────────────────────── - -async function suiteSetupDisable() { - group('19 · 设置页面禁用(单向操作)'); - - if (!isNewRegistration) { - skip('POST /setup/disable', '非全新注册,跳过此破坏性操作'); - skip('GET / 禁用后 → 404', '跳过'); - return; - } - - await test('POST /setup/disable 禁用设置页面', async () => { - const { status, body } = await api('/setup/disable', { method: 'POST', auth: false }); - return { ok: status === 200 && body?.success === true }; - }); - - await test('GET / 禁用后 → 404', async () => { - const resp = await fetch(`${BASE}/`); - return { ok: resp.status === 404 }; - }); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 汇总报告 -// ═══════════════════════════════════════════════════════════════════════════ - -function printSummary(): number { - const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIP: 0 }; - for (const r of results) counts[r.status]++; - const total = results.length; - const totalMs = results.reduce((s, r) => s + r.ms, 0); - - console.log(`\n${c.bold}${c.white}${'═'.repeat(60)}${c.reset}`); - console.log(`${c.bold} NodeWarden 自查报告${c.reset}`); - console.log(`${'═'.repeat(60)}`); - console.log(` ${c.green}通过 ${counts.PASS}${c.reset} │ ${c.red}失败 ${counts.FAIL}${c.reset} │ ${c.yellow}未实现 ${counts.WARN}${c.reset} │ ${c.gray}跳过 ${counts.SKIP}${c.reset} │ 总计 ${total}`); - console.log(` 耗时: ${(totalMs / 1000).toFixed(2)}s`); - console.log(`${'─'.repeat(60)}`); - - // 失败项 - const failures = results.filter(r => r.status === 'FAIL'); - if (failures.length) { - console.log(`\n${c.red}${c.bold} 失败项:${c.reset}`); - for (const f of failures) { - console.log(` ${c.red}✘ [${f.group}] ${f.name}${c.reset}`); - if (f.detail) console.log(` ${c.dim}${f.detail}${c.reset}`); - } - } - - // 未实现项 - const warns = results.filter(r => r.status === 'WARN'); - if (warns.length) { - console.log(`\n${c.yellow}${c.bold} 尚未实现的功能(${warns.length} 项):${c.reset}`); - for (const w of warns) { - console.log(` ${c.yellow}⚠ ${w.name}${c.reset} ${c.dim}${w.detail || ''}${c.reset}`); - } - } - - console.log(`\n${c.bold} 分组汇总:${c.reset}`); - const groups = new Map(); - for (const r of results) { - if (!groups.has(r.group)) groups.set(r.group, { pass: 0, fail: 0, warn: 0, total: 0 }); - const g = groups.get(r.group)!; - g.total++; - if (r.status === 'PASS') g.pass++; - if (r.status === 'FAIL') g.fail++; - if (r.status === 'WARN') g.warn++; - } - for (const [name, g] of groups) { - const icon = g.fail > 0 ? `${c.red}✘` : g.warn > 0 ? `${c.yellow}⚠` : `${c.green}✔`; - const warnStr = g.warn > 0 ? ` ${c.yellow}${g.warn} 未实现${c.reset}` : ''; - console.log(` ${icon} ${c.reset}${name} ${c.dim}(${g.pass}/${g.total})${c.reset}${warnStr}`); - } - - console.log(`\n${'═'.repeat(60)}`); - if (counts.FAIL === 0 && counts.WARN === 0) { - console.log(`${c.green}${c.bold} ✔ 全部检查通过!NodeWarden 兼容全平台 Bitwarden 客户端。${c.reset}`); - } else if (counts.FAIL === 0) { - console.log(`${c.green}${c.bold} ✔ 已实现功能全部通过!${c.reset}${c.yellow} ⚠ ${counts.WARN} 个端点尚未实现。${c.reset}`); - } else { - console.log(`${c.red}${c.bold} ✘ ${counts.FAIL} 项检查未通过,请查看上方详情。${c.reset}`); - } - console.log(`${'═'.repeat(60)}\n`); - - return counts.FAIL; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 主流程 -// ═══════════════════════════════════════════════════════════════════════════ - -async function main() { - console.log(`\n${c.bold}${c.cyan}╔${'═'.repeat(58)}╗${c.reset}`); - console.log(`${c.bold}${c.cyan}║ NodeWarden 自查程序 · Bitwarden API 兼容性全面诊断 ║${c.reset}`); - console.log(`${c.bold}${c.cyan}╚${'═'.repeat(58)}╝${c.reset}`); - console.log(`${c.dim} 服务器 : ${BASE}${c.reset}`); - console.log(`${c.dim} 邮箱 : ${EMAIL}${c.reset}`); - console.log(`${c.dim} 密码 : ${'*'.repeat(PASSWORD.length)}${c.reset}`); - console.log(`${c.dim} 时间 : ${new Date().toISOString()}${c.reset}`); - - try { await fetch(`${BASE}/config`); } catch (e: any) { - console.error(`\n${c.red} ✘ 无法连接到服务器 ${BASE}${c.reset}`); - console.error(`${c.dim} 请先启动 NodeWarden: npm run dev${c.reset}`); - console.error(`${c.dim} ${e.message}${c.reset}\n`); - process.exit(1); - } - - await suiteConnectivity(); // 1. 连通性 + Config 深度 - await suiteCors(); // 2. CORS 深度验证 - await suiteRegistration(); // 3. 注册与设置 - await suiteAuth(); // 4. 认证(多平台 + JWT Claims) - await suiteRefresh(); // 5. 令牌刷新完整性 - await suiteEmptyVault(); // 6. 空保管库回归测试 - await suiteAccounts(); // 7. 账户端点 - await suiteSync(); // 8. 同步深度验证 - await suiteFolders(); // 9. 文件夹 - await suiteCiphers(); // 10. 密码项 - await suiteAttachments(); // 11. 附件 - await suiteStubs(); // 12. Stub 端点 + 通知 - await suiteIcons(); // 13. 图标代理 - await suiteAuthGuard(); // 14. 认证守卫 - await suiteBlocked(); // 15. 被阻止端点 - await suiteResponseSchema(); // 16. 响应格式合规 - await suiteCleanup(); // 17. 清理 - await suiteGapAnalysis(); // 18. 缺失端点分析 - await suiteSetupDisable(); // 19. 设置页面禁用 - - const failCount = printSummary(); - process.exit(failCount > 0 ? 1 : 0); -} - -main().catch(e => { - console.error(`\n${c.red}致命错误: ${e.message || e}${c.reset}\n`); - process.exit(2); -}); From 40549147bdab781e15f9ae03ff21f6020835d7e7 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 19:58:33 +0800 Subject: [PATCH 016/149] fix: update bitwarden server version to 2026.1.0 --- .tmp-bitwarden-server | 1 + .tmp-bwandroid | 1 + .tmp-bwclients | 1 + .tmp-vaultwarden | 1 + src/config/limits.ts | 2 +- 5 files changed, 5 insertions(+), 1 deletion(-) create mode 160000 .tmp-bitwarden-server create mode 160000 .tmp-bwandroid create mode 160000 .tmp-bwclients create mode 160000 .tmp-vaultwarden diff --git a/.tmp-bitwarden-server b/.tmp-bitwarden-server new file mode 160000 index 0000000..cfd5bed --- /dev/null +++ b/.tmp-bitwarden-server @@ -0,0 +1 @@ +Subproject commit cfd5bedae0e16aadba155c5553cbd814b8478fff diff --git a/.tmp-bwandroid b/.tmp-bwandroid new file mode 160000 index 0000000..1a69362 --- /dev/null +++ b/.tmp-bwandroid @@ -0,0 +1 @@ +Subproject commit 1a6936262c84939400818c1b499e07579b04dbe4 diff --git a/.tmp-bwclients b/.tmp-bwclients new file mode 160000 index 0000000..c9b8212 --- /dev/null +++ b/.tmp-bwclients @@ -0,0 +1 @@ +Subproject commit c9b821262c5f1589571645e44dddd02ec8bb51b1 diff --git a/.tmp-vaultwarden b/.tmp-vaultwarden new file mode 160000 index 0000000..1583fe4 --- /dev/null +++ b/.tmp-vaultwarden @@ -0,0 +1 @@ +Subproject commit 1583fe4af3b3ce98e221172d649567630b899a36 diff --git a/src/config/limits.ts b/src/config/limits.ts index fdaedde..3676db0 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -99,6 +99,6 @@ compatibility: { // Single source of truth for /config.version and /api/version. // /config.version 与 /api/version 的统一版本号来源。 - bitwardenServerVersion: '2025.12.0', + bitwardenServerVersion: '2026.1.0', }, } as const; From 1a22b108ca46b167cff9b73e24de4a0dec858b40 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 19 Feb 2026 21:13:59 +0800 Subject: [PATCH 017/149] style: enhance register page styling with grid background and button effects --- src/setup/pageTemplate.ts | 57 ++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/setup/pageTemplate.ts b/src/setup/pageTemplate.ts index 3c305b2..023fb5a 100644 --- a/src/setup/pageTemplate.ts +++ b/src/setup/pageTemplate.ts @@ -15,7 +15,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string @@ -696,13 +650,6 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string

      Your server is ready. Configure your Bitwarden client with this server URL:

    -
    -

    Hide setup page

    -

    -
    - -
    -
    @@ -731,18 +678,6 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string - - + +`; +} + +export async function handleWebClientPage(request: Request, env: Env): Promise { + void request; + void env; + return htmlResponse(renderWebClientHTML()); +} diff --git a/src/router.ts b/src/router.ts index 0b58c76..ddb24db 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,6 @@ import { Env, DEFAULT_DEV_SECRET } from './types'; import { AuthService } from './services/auth'; +import { StorageService } from './services/storage'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { handleCors, errorResponse, jsonResponse } from './utils/response'; import { LIMITS } from './config/limits'; @@ -8,7 +9,17 @@ import { LIMITS } from './config/limits'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; // Account handlers -import { handleRegister, handleGetProfile, handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword } from './handlers/accounts'; +import { + handleRegister, + handleGetProfile, + handleUpdateProfile, + handleSetKeys, + handleGetRevisionDate, + handleVerifyPassword, + handleChangePassword, + handleGetTotpStatus, + handleSetTotpStatus, +} from './handlers/accounts'; // Cipher handlers import { @@ -38,6 +49,7 @@ import { handleSync } from './handlers/sync'; // Setup handlers import { handleSetupPage, handleSetupStatus } from './handlers/setup'; +import { handleWebClientPage } from './handlers/web'; import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; // Import handler @@ -51,6 +63,14 @@ import { handleDeleteAttachment, handlePublicDownloadAttachment, } from './handlers/attachments'; +import { + handleAdminListUsers, + handleAdminCreateInvite, + handleAdminListInvites, + handleAdminRevokeInvite, + handleAdminSetUserStatus, + handleAdminDeleteUser, +} from './handlers/admin'; function isSameOriginWriteRequest(request: Request): boolean { const targetOrigin = new URL(request.url).origin; @@ -166,8 +186,13 @@ export async function handleRequest(request: Request, env: Env): Promise { + const admin = await this.db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>(); + if (admin?.id) return; + + const firstUser = await this.db + .prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1') + .first<{ id: string }>(); + if (!firstUser?.id) return; + + await this.db + .prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?") + .bind(new Date().toISOString(), firstUser.id) + .run(); + } + // --- Config / setup --- async isRegistered(): Promise { @@ -164,14 +196,7 @@ export class StorageService { // --- Users --- - async getUser(email: string): Promise { - const row = await this.db - .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE email = ?' - ) - .bind(email.toLowerCase()) - .first(); - if (!row) return null; + private mapUserRow(row: any): User { return { id: row.id, email: row.email, @@ -185,45 +210,58 @@ export class StorageService { kdfMemory: row.kdf_memory ?? undefined, kdfParallelism: row.kdf_parallelism ?? undefined, securityStamp: row.security_stamp, + role: row.role === 'admin' ? 'admin' : 'user', + status: row.status === 'banned' ? 'banned' : 'active', + totpSecret: row.totp_secret ?? null, createdAt: row.created_at, updatedAt: row.updated_at, }; } + async getUser(email: string): Promise { + const row = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE email = ?' + ) + .bind(email.toLowerCase()) + .first(); + if (!row) return null; + return this.mapUserRow(row); + } + async getUserById(id: string): Promise { const row = await this.db .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE id = ?' ) .bind(id) .first(); if (!row) return null; - return { - id: row.id, - email: row.email, - name: row.name, - masterPasswordHash: row.master_password_hash, - key: row.key, - privateKey: row.private_key, - publicKey: row.public_key, - kdfType: row.kdf_type, - kdfIterations: row.kdf_iterations, - kdfMemory: row.kdf_memory ?? undefined, - kdfParallelism: row.kdf_parallelism ?? undefined, - securityStamp: row.security_stamp, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return this.mapUserRow(row); + } + + async getUserCount(): Promise { + const row = await this.db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>(); + return Number(row?.count || 0); + } + + async getAllUsers(): Promise { + const res = await this.db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users ORDER BY created_at ASC' + ) + .all(); + return (res.results || []).map(row => this.mapUserRow(row)); } async saveUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, updated_at=excluded.updated_at' ); await this.safeBind(stmt, user.id, @@ -238,16 +276,23 @@ export class StorageService { user.kdfMemory, user.kdfParallelism, user.securityStamp, + user.role, + user.status, + user.totpSecret, user.createdAt, user.updatedAt ).run(); } + async createUser(user: User): Promise { + await this.saveUser(user); + } + async createFirstUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await this.safeBind(stmt, @@ -263,6 +308,9 @@ export class StorageService { user.kdfMemory, user.kdfParallelism, user.securityStamp, + user.role, + user.status, + user.totpSecret, user.createdAt, user.updatedAt ).run(); @@ -270,6 +318,89 @@ export class StorageService { return (result.meta.changes ?? 0) > 0; } + async deleteUserById(id: string): Promise { + const result = await this.db.prepare('DELETE FROM users WHERE id = ?').bind(id).run(); + return (result.meta.changes ?? 0) > 0; + } + + async createInvite(invite: Invite): Promise { + await this.db + .prepare( + 'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt) + .run(); + } + + async getInvite(code: string): Promise { + const row = await this.db + .prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?') + .bind(code) + .first(); + if (!row) return null; + return { + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async listInvites(includeInactive: boolean = false): Promise { + const now = new Date().toISOString(); + const predicate = includeInactive + ? '1 = 1' + : "(status = 'active' AND expires_at > ?)"; + const query = + 'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' + + `WHERE ${predicate} ORDER BY created_at DESC`; + const res = includeInactive + ? await this.db.prepare(query).all() + : await this.db.prepare(query).bind(now).all(); + + return (res.results || []).map(row => ({ + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + } + + async markInviteUsed(code: string, userId: string): Promise { + const now = new Date().toISOString(); + const result = await this.db + .prepare( + "UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" + ) + .bind(userId, now, code, now) + .run(); + return (result.meta.changes ?? 0) > 0; + } + + async revokeInvite(code: string): Promise { + const now = new Date().toISOString(); + const result = await this.db + .prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") + .bind(now, code) + .run(); + return (result.meta.changes ?? 0) > 0; + } + + async createAuditLog(log: AuditLog): Promise { + await this.db + .prepare( + 'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) + .run(); + } + // --- Ciphers --- async getCipher(id: string): Promise { @@ -632,6 +763,10 @@ export class StorageService { await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); } + async deleteRefreshTokensByUserId(userId: string): Promise { + await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); + } + // Keep a short overlap window for rotated refresh token to reduce // multi-context refresh races (e.g. browser extension popup/background). // Expiry is only tightened, never extended. diff --git a/src/types/index.ts b/src/types/index.ts index cb7fed9..92cd0b8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,9 @@ export interface Env { TOTP_SECRET?: string; } +export type UserRole = 'admin' | 'user'; +export type UserStatus = 'active' | 'banned'; + // Sample JWT secret used by `.dev.vars.example`. // If runtime JWT_SECRET equals this value, treat it as unsafe. export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters'; @@ -34,10 +37,33 @@ export interface User { kdfMemory?: number; kdfParallelism?: number; securityStamp: string; + role: UserRole; + status: UserStatus; + totpSecret: string | null; createdAt: string; updatedAt: string; } +export interface Invite { + code: string; + createdBy: string; + usedBy: string | null; + expiresAt: string; + status: 'active' | 'used' | 'revoked' | 'expired'; + createdAt: string; + updatedAt: string; +} + +export interface AuditLog { + id: string; + actorUserId: string | null; + action: string; + targetType: string | null; + targetId: string | null; + metadata: string | null; + createdAt: string; +} + // Cipher types export enum CipherType { Login = 1, @@ -235,6 +261,8 @@ export interface ProfileResponse { forcePasswordReset: boolean; avatarColor: string | null; creationDate: string; + role?: UserRole; + status?: UserStatus; object: string; } From 90da97c945cb13684e4b07e99e33fa01ce8e8bea Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 26 Feb 2026 04:26:19 +0800 Subject: [PATCH 039/149] feat: enhance registration and password management UI with additional state handling --- src/handlers/web.ts | 69 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/src/handlers/web.ts b/src/handlers/web.ts index 229a3b0..f07fc03 100644 --- a/src/handlers/web.ts +++ b/src/handlers/web.ts @@ -234,6 +234,10 @@ function renderWebClientHTML(): string { msg: '', msgType: 'ok', inviteCode: '', + registerName: '', + registerEmail: '', + registerPassword: '', + registerPassword2: '', session: null, profile: null, tab: 'vault', @@ -348,7 +352,7 @@ function renderWebClientHTML(): string { var it=Number(d.kdfIterations||defaultKdfIterations); var mk=await pbkdf2(password,email.toLowerCase(),it,32); var h=await pbkdf2(mk,password,1,32); - return { hash: bytesToBase64(h), masterKey: mk }; + return { hash: bytesToBase64(h), masterKey: mk, kdfIterations: it }; } function logout(){ @@ -376,6 +380,20 @@ function renderWebClientHTML(): string { function randomBase32Secret(len){ var a='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; var b=crypto.getRandomValues(new Uint8Array(len)); var o=''; for(var i=0;i
    ' @@ -404,9 +422,9 @@ function renderWebClientHTML(): string { + '

    Register

    ' + renderMsg() + '
    ' - + '
    ' - + '
    ' - + '
    ' + + '
    ' + + '
    ' + + '
    ' + '
    ' + '
    ' + '
    ' @@ -458,6 +476,7 @@ function renderWebClientHTML(): string { return '' + renderMsg() + '

    Profile

    ' + + '

    Master Password

    After success, current sessions are revoked and you must log in again.
    ' + '

    TOTP Setup

    TOTP QR
    Disable action prompts for master password.
    '; } function renderTotpDisableModal(){ @@ -471,8 +490,9 @@ function renderWebClientHTML(): string { function renderHelpTab(){ return '' - + '

    Upstream Sync

    • Use fork + GitHub Actions scheduled sync.
    • Or use manual Sync fork from repository page.
    • Deploy updated branch in Cloudflare Worker after sync.
    ' - + '

    Common Errors

    • 401 Unauthorized: login again.
    • 429 Too many requests: wait and retry.
    • 403 Invite invalid: check invite status and expiry.
    • Disabled user cannot login.
    '; + + '

    Upstream Sync

    • Track upstream with a fork and scheduled sync workflow (recommended).
    • Before merge: compare API routes, migration files, and auth logic changes.
    • After merge: run local dev migration tests, then deploy Worker after validation.
    ' + + '

    Common Errors

    • 401 Unauthorized: token expired or revoked, login again.
    • 403 Account disabled: admin must unban user in User Management.
    • 403 Invite invalid: invite expired/used/revoked, create a new invite.
    • 429 Too many requests: wait retry seconds and avoid burst writes.
    ' + + '

    Troubleshooting

    • Login OK but encrypted values shown: verify profile key and KDF settings are consistent.
    • TOTP fails repeatedly: sync device time and re-scan QR using latest secret.
    • Password change failed: ensure current password is correct and new password has at least 12 chars.
    • Sync conflicts: refresh vault and retry one operation at a time.
    '; } function renderAdminTab(){ @@ -543,6 +563,7 @@ function renderWebClientHTML(): string { async function onRegister(form){ clearMsg(); var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); + state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; if(!email||!p) return setMsg('Please input email and password.', 'err'); if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); if(p!==p2) return setMsg('Passwords do not match.', 'err'); @@ -552,6 +573,7 @@ function renderWebClientHTML(): string { var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); + state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } } @@ -595,6 +617,40 @@ function renderWebClientHTML(): string { setMsg('Login success.', 'ok'); } async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } + async function onChangePassword(form){ + var fd=new FormData(form); + var currentPassword=String(fd.get('currentPassword')||''); + var newPassword=String(fd.get('newPassword')||''); + var newPassword2=String(fd.get('newPassword2')||''); + if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); + if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); + if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); + if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); + var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); + if(!email) return setMsg('Profile email missing.', 'err'); + try{ + var current=await deriveLoginHash(email,currentPassword); + var userSym=buildSymmetricKeyBytes(); + if(!userSym){ + var oldEk=await hkdfExpand(current.masterKey,'enc',32); + var oldEm=await hkdfExpand(current.masterKey,'mac',32); + userSym=await decryptBw(state.profile.key,oldEk,oldEm); + } + if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); + var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); + var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); + var nextEk=await hkdfExpand(nextMasterKey,'enc',32); + var nextEm=await hkdfExpand(nextMasterKey,'mac',32); + var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); + var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); + var j=await jsonOrNull(r); + if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); + logout(); + setMsg('Master password changed. Please log in again.', 'ok'); + }catch(e){ + setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); + } + } async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } async function onDisableTotpSubmit(form){ @@ -627,6 +683,7 @@ function renderWebClientHTML(): string { if(form.id==='loginForm') return void onLoginPassword(form); if(form.id==='loginTotpForm') return void onLoginTotp(form); if(form.id==='profileForm') return void onSaveProfile(form); + if(form.id==='passwordForm') return void onChangePassword(form); if(form.id==='totpEnableForm') return void onEnableTotp(form); if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form); if(form.id==='inviteForm') return void onCreateInvite(form); From b21b031120efb970d0fff026c2eed4a60e12d103 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 26 Feb 2026 04:54:11 +0800 Subject: [PATCH 040/149] Refactor code structure for improved readability and maintainability --- src/handlers/setup.ts | 29 +- src/router.ts | 9 +- src/setup/pageTemplate.ts | 1453 ------------------------------------- 3 files changed, 4 insertions(+), 1487 deletions(-) delete mode 100644 src/setup/pageTemplate.ts diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 517b1c1..b799903 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -1,31 +1,6 @@ -import { Env, DEFAULT_DEV_SECRET } from '../types'; +import { Env } from '../types'; import { StorageService } from '../services/storage'; -import { jsonResponse, htmlResponse } from '../utils/response'; -import { renderRegisterPageHTML } from '../setup/pageTemplate'; -import { LIMITS } from '../config/limits'; - -type JwtSecretState = 'missing' | 'default' | 'too_short'; - -function getJwtSecretState(env: Env): JwtSecretState | null { - const secret = (env.JWT_SECRET || '').trim(); - if (!secret) return 'missing'; - // Block common "forgot to change" sample value (matches .dev.vars.example) - if (secret === DEFAULT_DEV_SECRET) return 'default'; - if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; - return null; -} - -async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { - void request; - void env; - return htmlResponse(renderRegisterPageHTML(jwtState)); -} - -// GET / - Setup page -export async function handleSetupPage(request: Request, env: Env): Promise { - const jwtState = getJwtSecretState(env); - return handleRegisterPage(request, env, jwtState); -} +import { jsonResponse } from '../utils/response'; // GET /setup/status export async function handleSetupStatus(request: Request, env: Env): Promise { diff --git a/src/router.ts b/src/router.ts index ddb24db..6a2b472 100644 --- a/src/router.ts +++ b/src/router.ts @@ -48,7 +48,7 @@ import { import { handleSync } from './handlers/sync'; // Setup handlers -import { handleSetupPage, handleSetupStatus } from './handlers/setup'; +import { handleSetupStatus } from './handlers/setup'; import { handleWebClientPage } from './handlers/web'; import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; @@ -187,15 +187,10 @@ export async function handleRequest(request: Request, env: Env): Promise - - - - - NodeWarden - - - -
    - - -
    -
    - -
    -
    - - - - - - -
    -
    - -
    -
    -
    - - - -`; -} From ec9be40d6c6e01e26fb140b30e7b516d921b4ca6 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 26 Feb 2026 05:04:43 +0800 Subject: [PATCH 041/149] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=A0=B7=E5=BC=8F=E5=92=8C?= =?UTF-8?q?=E5=B8=83=E5=B1=80=EF=BC=8C=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handlers/web.ts | 380 +++++++++++++++++++++++++++----------------- 1 file changed, 237 insertions(+), 143 deletions(-) diff --git a/src/handlers/web.ts b/src/handlers/web.ts index f07fc03..c4d618a 100644 --- a/src/handlers/web.ts +++ b/src/handlers/web.ts @@ -13,213 +13,298 @@ function renderWebClientHTML(): string { NodeWarden Web @@ -403,12 +488,12 @@ function renderWebClientHTML(): string { + '
    ' + '
    ' + '
    ' - + '
    ' + + '
    ' + '
    ' + (state.pendingLogin ? '' + '

    Two-step verification

    Password is already verified.
    ' + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') - + '
    ' + + '
    ' + '
    ' : '') + '
    ' @@ -426,7 +511,7 @@ function renderWebClientHTML(): string { + '
    ' + '
    ' + '
    ' - + '
    ' + + '
    ' + ' ' + ' ' + '
    '; @@ -438,12 +523,12 @@ function renderWebClientHTML(): string { for(var i=0;i
    '+esc(nameText)+'
    '+esc(c.id)+'
    '; + rows += '
    '+esc(nameText)+'
    '+esc(c.id)+'
    '; } - if(!rows) rows='
    No items in this folder.
    '; + if(!rows) rows='
    No items in this folder.
    '; var c0=selectedCipher(); - var detail='
    Select an item to view details.
    '; + var detail='
    Select an item to view details.
    '; if(c0){ var login = c0.login||{}; var fields=Array.isArray(c0.fields)?c0.fields:[]; @@ -463,10 +548,11 @@ function renderWebClientHTML(): string { return '' + renderMsg() - + '

    Vault

    ' - + '
    ' - + '
    '+rows+'
    '+detail+'
    ' - + '
    '; + + '
    ' + + '

    Vault

    ' + + '
    ' + + '
    ' + + '
    '+rows+'
    '+detail+'
    '; } function renderSettingsTab(){ @@ -475,52 +561,58 @@ function renderWebClientHTML(): string { var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); return '' + renderMsg() + + '

    Settings

    ' + '

    Profile

    ' + '

    Master Password

    After success, current sessions are revoked and you must log in again.
    ' - + '

    TOTP Setup

    TOTP QR
    Disable action prompts for master password.
    '; + + '

    TOTP Setup

    TOTP QR
    Disable action prompts for master password.
    '; } function renderTotpDisableModal(){ if(!state.totpDisableOpen) return ''; return '' - + '

    Disable TOTP

    Enter master password to disable two-step verification.
    ' - + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') - + '
    ' + + '

    Disable TOTP

    Enter master password to disable two-step verification.
    ' + + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') + + '
    ' + '
    '; } function renderHelpTab(){ return '' - + '

    Upstream Sync

    • Track upstream with a fork and scheduled sync workflow (recommended).
    • Before merge: compare API routes, migration files, and auth logic changes.
    • After merge: run local dev migration tests, then deploy Worker after validation.
    ' - + '

    Common Errors

    • 401 Unauthorized: token expired or revoked, login again.
    • 403 Account disabled: admin must unban user in User Management.
    • 403 Invite invalid: invite expired/used/revoked, create a new invite.
    • 429 Too many requests: wait retry seconds and avoid burst writes.
    ' - + '

    Troubleshooting

    • Login OK but encrypted values shown: verify profile key and KDF settings are consistent.
    • TOTP fails repeatedly: sync device time and re-scan QR using latest secret.
    • Password change failed: ensure current password is correct and new password has at least 12 chars.
    • Sync conflicts: refresh vault and retry one operation at a time.
    '; + + '

    Help & Support

    ' + + '

    Upstream Sync

    • Track upstream with a fork and scheduled sync workflow (recommended).
    • Before merge: compare API routes, migration files, and auth logic changes.
    • After merge: run local dev migration tests, then deploy Worker after validation.
    ' + + '

    Common Errors

    • 401 Unauthorized: token expired or revoked, login again.
    • 403 Account disabled: admin must unban user in User Management.
    • 403 Invite invalid: invite expired/used/revoked, create a new invite.
    • 429 Too many requests: wait retry seconds and avoid burst writes.
    ' + + '

    Troubleshooting

    • Login OK but encrypted values shown: verify profile key and KDF settings are consistent.
    • TOTP fails repeatedly: sync device time and re-scan QR using latest secret.
    • Password change failed: ensure current password is correct and new password has at least 12 chars.
    • Sync conflicts: refresh vault and retry one operation at a time.
    '; } function renderAdminTab(){ var usersRows=''; for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' - + (canAct?'':'') + usersRows += ''+esc(u.email)+''+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' + + (canAct?'':'') + (canAct?' ':'') + ''; } - if(!usersRows) usersRows='No users.'; + if(!usersRows) usersRows='No users found.'; var inviteRows=''; for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' - + '' + inviteRows += ''+esc(inv.code)+''+esc(inv.status)+''+esc(inv.expiresAt)+'' + + '' + (inv.status==='active'?' ':'') + ''; } - if(!inviteRows) inviteRows='No invites.'; + if(!inviteRows) inviteRows='No invites found.'; return '' + renderMsg() - + '

    Create Invite

    ' - + '

    Users

    '+usersRows+'
    EmailNameRoleStatusAction
    ' - + '

    Invites

    '+inviteRows+'
    CodeStatusExpires AtAction
    '; + + '
    ' + + '

    User Management

    ' + + '' + + '
    ' + + '

    Create Invite

    ' + + '

    Users

    '+usersRows+'
    EmailNameRoleStatusAction
    ' + + '

    Invites

    '+inviteRows+'
    CodeStatusExpires AtAction
    '; } function renderApp(){ @@ -534,18 +626,20 @@ function renderWebClientHTML(): string { return '' + '
    ' + ' ' - + (showFolders?(' '):'') + + ' ' + + '
    ' + + ' ' + + (showFolders?(' '):'') + '
    '+content+'
    ' + '
    '+renderTotpDisableModal()+'
    '; } function render(){ - if(state.phase==='loading'){ app.innerHTML='

    Loading...

    '; return; } + if(state.phase==='loading'){ app.innerHTML='
    NW
    Loading NodeWarden...
    '; return; } if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } app.innerHTML=renderApp(); From ee784d18db8d5a44fd118259181846fcbc1cc533 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 26 Feb 2026 05:35:29 +0800 Subject: [PATCH 042/149] Implement code changes to enhance functionality and improve performance --- src/handlers/web.ts | 844 ++++++++++++++++++++++++++++---------------- 1 file changed, 536 insertions(+), 308 deletions(-) diff --git a/src/handlers/web.ts b/src/handlers/web.ts index c4d618a..313d1f0 100644 --- a/src/handlers/web.ts +++ b/src/handlers/web.ts @@ -13,298 +13,352 @@ function renderWebClientHTML(): string { NodeWarden Web @@ -316,6 +370,7 @@ function renderWebClientHTML(): string { var defaultKdfIterations = ${defaultKdfIterations}; var state = { phase: 'loading', + lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', msg: '', msgType: 'ok', inviteCode: '', @@ -346,13 +401,162 @@ function renderWebClientHTML(): string { }; var NO_FOLDER_FILTER = '__none__'; + var i18n = { + en: { + brand: 'NodeWarden', + subtitle: 'Open Source Password Manager', + login: 'Log In', + register: 'Create Account', + email: 'Email Address', + masterPwd: 'Master Password', + confirmPwd: 'Confirm Master Password', + name: 'Name', + inviteCode: 'Invite Code (Optional)', + loginBtn: 'Log In', + registerBtn: 'Create Account', + backToLogin: 'Back to Log In', + vault: 'Vault', + settings: 'Settings', + admin: 'Admin', + help: 'Help', + logout: 'Log Out', + folders: 'Folders', + allItems: 'All Items', + noFolder: 'No Folder', + refresh: 'Refresh', + move: 'Move', + delete: 'Delete', + selectAll: 'Select All', + clear: 'Clear', + noItems: 'There are no items to list.', + selectItem: 'Select an item to view details.', + profile: 'Profile', + saveProfile: 'Save Profile', + changePwd: 'Change Master Password', + currentPwd: 'Current Master Password', + newPwd: 'New Master Password', + totpSetup: 'Two-Step Login (TOTP)', + enableTotp: 'Enable TOTP', + disableTotp: 'Disable TOTP', + secret: 'Authenticator Key', + verifyCode: 'Verification Code', + users: 'Users', + invites: 'Invites', + createInvite: 'Create Invite', + expiresIn: 'Expires in (hours)', + copyLink: 'Copy Link', + revoke: 'Revoke', + ban: 'Ban', + unban: 'Unban', + status: 'Status', + role: 'Role', + action: 'Options', + loading: 'Loading NodeWarden...', + totpVerify: 'Two-step verification', + totpVerifySub: 'Password is already verified.', + totpCode: 'TOTP Code', + verify: 'Verify', + cancel: 'Cancel', + totpDisableSub: 'Enter master password to disable two-step verification.', + helpSync: 'Upstream Sync', + helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).', + helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.', + helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.', + helpErr: 'Common Errors', + helpErr1: '401 Unauthorized: token expired or revoked, login again.', + helpErr2: '403 Account disabled: admin must unban user in User Management.', + helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.', + helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.', + helpTb: 'Troubleshooting', + helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.', + helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.', + helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.', + helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.', + langSwitch: '中文' + }, + zh: { + brand: 'NodeWarden', + subtitle: '开源密码管理器', + login: '登录', + register: '创建账号', + email: '电子邮件地址', + masterPwd: '主密码', + confirmPwd: '确认主密码', + name: '姓名', + inviteCode: '邀请码 (可选)', + loginBtn: '登录', + registerBtn: '创建账号', + backToLogin: '返回登录', + vault: '密码库', + settings: '设置', + admin: '管理', + help: '帮助', + logout: '退出登录', + folders: '文件夹', + allItems: '所有项目', + noFolder: '无文件夹', + refresh: '刷新', + move: '移动', + delete: '删除', + selectAll: '全选', + clear: '清除', + noItems: '没有可列出的项目。', + selectItem: '选择一个项目以查看详细信息。', + profile: '个人资料', + saveProfile: '保存个人资料', + changePwd: '更改主密码', + currentPwd: '当前主密码', + newPwd: '新主密码', + totpSetup: '两步登录 (TOTP)', + enableTotp: '启用 TOTP', + disableTotp: '禁用 TOTP', + secret: '身份验证器密钥', + verifyCode: '验证码', + users: '用户', + invites: '邀请', + createInvite: '创建邀请', + expiresIn: '过期时间 (小时)', + copyLink: '复制链接', + revoke: '撤销', + ban: '封禁', + unban: '解封', + status: '状态', + role: '角色', + action: '选项', + loading: '正在加载 NodeWarden...', + totpVerify: '两步验证', + totpVerifySub: '密码已验证。', + totpCode: 'TOTP 验证码', + verify: '验证', + cancel: '取消', + totpDisableSub: '输入主密码以禁用两步验证。', + helpSync: '上游同步', + helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。', + helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。', + helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。', + helpErr: '常见错误', + helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。', + helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。', + helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。', + helpErr4: '429 请求过多:等待重试时间,避免突发写入。', + helpTb: '故障排除', + helpTb1: '登录成功但显示加密值:验证个人资料密钥和 KDF 设置是否一致。', + helpTb2: 'TOTP 反复失败:同步设备时间并使用最新密钥重新扫描二维码。', + helpTb3: '密码更改失败:确保当前密码正确且新密码至少 12 个字符。', + helpTb4: '同步冲突:刷新密码库并一次重试一个操作。', + langSwitch: 'English' + } + }; + + function t(key) { return i18n[state.lang][key] || key; } + function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function sessionKey() { return 'nodewarden.web.session.v2'; } function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } function clearMsg() { state.msg = ''; } - function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } + function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } function bytesToBase64(bytes) { var s=''; for (var i=0;i
    ' - + ' ' - + '

    Sign In

    ' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('brand')+'
    ' + + '
    '+t('subtitle')+'
    ' + + '
    ' + renderMsg() + '
    ' - + '
    ' - + '
    ' - + '
    ' + + '
    ' + + '
    ' + + ' ' + '
    ' + + ' ' + (state.pendingLogin ? '' - + '

    Two-step verification

    Password is already verified.
    ' - + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') - + '
    ' + + '

    '+t('totpVerify')+'

    '+t('totpVerifySub')+'
    ' + + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') + + '
    ' + '
    ' : '') - + '
    ' - + '
    '; + + ' ' + + ''; } function renderRegisterScreen(){ return '' - + '
    ' - + ' ' - + '

    Register

    ' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('register')+'
    ' + + '
    '+t('brand')+'
    ' + + '
    ' + renderMsg() + '
    ' - + '
    ' - + '
    ' - + '
    ' - + '
    ' - + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + '
    ' - + '
    ' - + '
    '; + + ' ' + + ' ' + + ''; } function renderVaultTab(){ @@ -525,10 +746,10 @@ function renderWebClientHTML(): string { var nameText=(c.decName||c.name||c.id); rows += '
    '+esc(nameText)+'
    '+esc(c.id)+'
    '; } - if(!rows) rows='
    No items in this folder.
    '; + if(!rows) rows='
    '+t('noItems')+'
    '; var c0=selectedCipher(); - var detail='
    Select an item to view details.
    '; + var detail='
    '+t('selectItem')+'
    '; if(c0){ var login = c0.login||{}; var fields=Array.isArray(c0.fields)?c0.fields:[]; @@ -549,8 +770,8 @@ function renderWebClientHTML(): string { return '' + renderMsg() + '
    ' - + '

    Vault

    ' - + '
    ' + + '

    '+t('vault')+'

    ' + + '
    ' + '
    ' + '
    '+rows+'
    '+detail+'
    '; } @@ -561,26 +782,26 @@ function renderWebClientHTML(): string { var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); return '' + renderMsg() - + '

    Settings

    ' - + '

    Profile

    ' - + '

    Master Password

    After success, current sessions are revoked and you must log in again.
    ' - + '

    TOTP Setup

    TOTP QR
    Disable action prompts for master password.
    '; + + '

    '+t('settings')+'

    ' + + '

    '+t('profile')+'

    ' + + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' + + '

    '+t('totpSetup')+'

    TOTP QR
    Disable action prompts for master password.
    '; } function renderTotpDisableModal(){ if(!state.totpDisableOpen) return ''; return '' - + '

    Disable TOTP

    Enter master password to disable two-step verification.
    ' - + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') - + '
    ' + + '

    '+t('disableTotp')+'

    '+t('totpDisableSub')+'
    ' + + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') + + '
    ' + '
    '; } function renderHelpTab(){ return '' - + '

    Help & Support

    ' - + '

    Upstream Sync

    • Track upstream with a fork and scheduled sync workflow (recommended).
    • Before merge: compare API routes, migration files, and auth logic changes.
    • After merge: run local dev migration tests, then deploy Worker after validation.
    ' - + '

    Common Errors

    • 401 Unauthorized: token expired or revoked, login again.
    • 403 Account disabled: admin must unban user in User Management.
    • 403 Invite invalid: invite expired/used/revoked, create a new invite.
    • 429 Too many requests: wait retry seconds and avoid burst writes.
    ' - + '

    Troubleshooting

    • Login OK but encrypted values shown: verify profile key and KDF settings are consistent.
    • TOTP fails repeatedly: sync device time and re-scan QR using latest secret.
    • Password change failed: ensure current password is correct and new password has at least 12 chars.
    • Sync conflicts: refresh vault and retry one operation at a time.
    '; + + '

    '+t('help')+'

    ' + + '

    '+t('helpSync')+'

    • '+t('helpSync1')+'
    • '+t('helpSync2')+'
    • '+t('helpSync3')+'
    ' + + '

    '+t('helpErr')+'

    • '+t('helpErr1')+'
    • '+t('helpErr2')+'
    • '+t('helpErr3')+'
    • '+t('helpErr4')+'
    ' + + '

    '+t('helpTb')+'

    • '+t('helpTb1')+'
    • '+t('helpTb2')+'
    • '+t('helpTb3')+'
    • '+t('helpTb4')+'
    '; } function renderAdminTab(){ @@ -588,8 +809,8 @@ function renderWebClientHTML(): string { for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' - + (canAct?'':'') - + (canAct?' ':'') + + (canAct?'':'') + + (canAct?' ':'') + ''; } if(!usersRows) usersRows='No users found.'; @@ -598,8 +819,8 @@ function renderWebClientHTML(): string { for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' - + '' - + (inv.status==='active'?' ':'') + + '' + + (inv.status==='active'?' ':'') + ''; } if(!inviteRows) inviteRows='No invites found.'; @@ -607,39 +828,45 @@ function renderWebClientHTML(): string { return '' + renderMsg() + '
    ' - + '

    User Management

    ' - + '' + + '

    '+t('admin')+'

    ' + + '' + '
    ' - + '

    Create Invite

    ' - + '

    Users

    '+usersRows+'
    EmailNameRoleStatusAction
    ' - + '

    Invites

    '+inviteRows+'
    CodeStatusExpires AtAction
    '; + + '

    '+t('createInvite')+'

    ' + + '

    '+t('users')+'

    '+usersRows+'
    '+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
    ' + + '

    '+t('invites')+'

    '+inviteRows+'
    Code'+t('status')+'Expires At'+t('action')+'
    '; } function renderApp(){ var isAdmin=state.profile&&state.profile.role==='admin'; var showFolders=state.tab==='vault'; - var folders='' - + ''; + var folders='' + + ''; for(var i=0;i'+esc(folderName)+''; } var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); - var layoutClass=showFolders?'vault-layout':'normal-layout'; + return '' - + '
    ' - + ' ' - + (showFolders?(' '):'') + + '' + + '
    ' + + (showFolders?(' '):'') + '
    '+content+'
    ' - + '
    '+renderTotpDisableModal()+'
    '; + + '
    '+renderTotpDisableModal(); } function render(){ - if(state.phase==='loading'){ app.innerHTML='
    NW
    Loading NodeWarden...
    '; return; } + if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    '; return; } if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } app.innerHTML=renderApp(); @@ -785,6 +1012,7 @@ function renderWebClientHTML(): string { app.addEventListener('click', function(ev){ var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return; + if(a==='toggle-lang'){ state.lang = state.lang === 'zh' ? 'en' : 'zh'; render(); return; } if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } From b10ce83ca001aefb697edf2b9daef338789748fe Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 00:38:05 +0800 Subject: [PATCH 043/149] Add global styles for web client interface --- src/handlers/web.ts | 1044 +-------------------------------------- src/webclient/page.ts | 25 + src/webclient/script.ts | 680 +++++++++++++++++++++++++ src/webclient/styles.ts | 352 +++++++++++++ 4 files changed, 1058 insertions(+), 1043 deletions(-) create mode 100644 src/webclient/page.ts create mode 100644 src/webclient/script.ts create mode 100644 src/webclient/styles.ts diff --git a/src/handlers/web.ts b/src/handlers/web.ts index 313d1f0..d3aea56 100644 --- a/src/handlers/web.ts +++ b/src/handlers/web.ts @@ -1,1048 +1,6 @@ import { Env } from '../types'; import { htmlResponse } from '../utils/response'; -import { LIMITS } from '../config/limits'; - -function renderWebClientHTML(): string { - const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; - - return ` - - - - - NodeWarden Web - - - -
    - - -`; -} +import { renderWebClientHTML } from '../webclient/page'; export async function handleWebClientPage(request: Request, env: Env): Promise { void request; diff --git a/src/webclient/page.ts b/src/webclient/page.ts new file mode 100644 index 0000000..9b2dbae --- /dev/null +++ b/src/webclient/page.ts @@ -0,0 +1,25 @@ +import { LIMITS } from '../config/limits'; +import { renderWebClientScript } from './script'; +import { WEB_CLIENT_STYLES } from './styles'; + +export function renderWebClientHTML(): string { + const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; + + return ` + + + + + NodeWarden Web + + + +
    + + +`; +} diff --git a/src/webclient/script.ts b/src/webclient/script.ts new file mode 100644 index 0000000..ae306f7 --- /dev/null +++ b/src/webclient/script.ts @@ -0,0 +1,680 @@ +export function renderWebClientScript(defaultKdfIterations: number): string { + return ` +(function () { + var app = document.getElementById('app'); + var defaultKdfIterations = ${defaultKdfIterations}; + var state = { + phase: 'loading', + lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', + msg: '', + msgType: 'ok', + inviteCode: '', + registerName: '', + registerEmail: '', + registerPassword: '', + registerPassword2: '', + session: null, + profile: null, + tab: 'vault', + ciphers: [], + folders: [], + folderFilterId: '', + selectedCipherId: '', + selectedMap: {}, + users: [], + invites: [], + loginEmail: '', + loginPassword: '', + loginTotpToken: '', + loginTotpError: '', + pendingLogin: null, + totpSetupSecret: '', + totpSetupToken: '', + totpDisableOpen: false, + totpDisablePassword: '', + totpDisableError: '' + }; + var NO_FOLDER_FILTER = '__none__'; + + var i18n = { + en: { + brand: 'NodeWarden', + subtitle: 'Open Source Password Manager', + login: 'Log In', + register: 'Create Account', + email: 'Email Address', + masterPwd: 'Master Password', + confirmPwd: 'Confirm Master Password', + name: 'Name', + inviteCode: 'Invite Code (Optional)', + loginBtn: 'Log In', + registerBtn: 'Create Account', + backToLogin: 'Back to Log In', + vault: 'Vault', + settings: 'Settings', + admin: 'Admin', + help: 'Help', + logout: 'Log Out', + folders: 'Folders', + allItems: 'All Items', + noFolder: 'No Folder', + refresh: 'Refresh', + move: 'Move', + delete: 'Delete', + selectAll: 'Select All', + clear: 'Clear', + noItems: 'There are no items to list.', + selectItem: 'Select an item to view details.', + profile: 'Profile', + saveProfile: 'Save Profile', + changePwd: 'Change Master Password', + currentPwd: 'Current Master Password', + newPwd: 'New Master Password', + totpSetup: 'Two-Step Login (TOTP)', + enableTotp: 'Enable TOTP', + disableTotp: 'Disable TOTP', + secret: 'Authenticator Key', + verifyCode: 'Verification Code', + users: 'Users', + invites: 'Invites', + createInvite: 'Create Invite', + expiresIn: 'Expires in (hours)', + copyLink: 'Copy Link', + revoke: 'Revoke', + ban: 'Ban', + unban: 'Unban', + status: 'Status', + role: 'Role', + action: 'Options', + loading: 'Loading NodeWarden...', + totpVerify: 'Two-step verification', + totpVerifySub: 'Password is already verified.', + totpCode: 'TOTP Code', + verify: 'Verify', + cancel: 'Cancel', + totpDisableSub: 'Enter master password to disable two-step verification.', + helpSync: 'Upstream Sync', + helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).', + helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.', + helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.', + helpErr: 'Common Errors', + helpErr1: '401 Unauthorized: token expired or revoked, login again.', + helpErr2: '403 Account disabled: admin must unban user in User Management.', + helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.', + helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.', + helpTb: 'Troubleshooting', + helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.', + helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.', + helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.', + helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.', + langSwitch: '中文' + }, + zh: { + brand: 'NodeWarden', + subtitle: '开源密码管理器', + login: '登录', + register: '创建账号', + email: '电子邮件地址', + masterPwd: '主密码', + confirmPwd: '确认主密码', + name: '姓名', + inviteCode: '邀请码 (可选)', + loginBtn: '登录', + registerBtn: '创建账号', + backToLogin: '返回登录', + vault: '密码库', + settings: '设置', + admin: '管理', + help: '帮助', + logout: '退出登录', + folders: '文件夹', + allItems: '所有项目', + noFolder: '无文件夹', + refresh: '刷新', + move: '移动', + delete: '删除', + selectAll: '全选', + clear: '清除', + noItems: '没有可列出的项目。', + selectItem: '选择一个项目以查看详细信息。', + profile: '个人资料', + saveProfile: '保存个人资料', + changePwd: '更改主密码', + currentPwd: '当前主密码', + newPwd: '新主密码', + totpSetup: '两步登录 (TOTP)', + enableTotp: '启用 TOTP', + disableTotp: '禁用 TOTP', + secret: '身份验证器密钥', + verifyCode: '验证码', + users: '用户', + invites: '邀请', + createInvite: '创建邀请', + expiresIn: '过期时间 (小时)', + copyLink: '复制链接', + revoke: '撤销', + ban: '封禁', + unban: '解封', + status: '状态', + role: '角色', + action: '选项', + loading: '正在加载 NodeWarden...', + totpVerify: '两步验证', + totpVerifySub: '密码已验证。', + totpCode: 'TOTP 验证码', + verify: '验证', + cancel: '取消', + totpDisableSub: '输入主密码以禁用两步验证。', + helpSync: '上游同步', + helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。', + helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。', + helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。', + helpErr: '常见错误', + helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。', + helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。', + helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。', + helpErr4: '429 请求过多:等待重试时间,避免突发写入。', + helpTb: '故障排除', + helpTb1: '登录成功但显示加密值:验证个人资料密钥和 KDF 设置是否一致。', + helpTb2: 'TOTP 反复失败:同步设备时间并使用最新密钥重新扫描二维码。', + helpTb3: '密码更改失败:确保当前密码正确且新密码至少 12 个字符。', + helpTb4: '同步冲突:刷新密码库并一次重试一个操作。', + langSwitch: 'English' + } + }; + + function t(key) { return i18n[state.lang][key] || key; } + + function esc(v) { + return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + function sessionKey() { return 'nodewarden.web.session.v2'; } + function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } + function clearMsg() { state.msg = ''; } + function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } + function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } + function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } + function bytesToBase64(bytes) { var s=''; for (var i=0;i=0){ type=parseInt(s.substring(0,dotIdx),10); rest=s.substring(dotIdx+1); } + else{ var pp=s.split('|'); type=(pp.length===3)?2:0; rest=s; } + var parts=rest.split('|'); + if(type===2&&parts.length===3) return {type:2,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:base64ToBytes(parts[2])}; + if((type===0||type===1||type===4)&&parts.length>=2) return {type:type,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:null}; + return null; + } + async function decryptAesCbc(data,key,iv){ var ck=await crypto.subtle.importKey('raw',key,{name:'AES-CBC'},false,['decrypt']); return new Uint8Array(await crypto.subtle.decrypt({name:'AES-CBC',iv:iv},ck,data)); } + async function decryptBw(cipherString,encKey,macKey){ + var parsed=parseCipherString(cipherString); if(!parsed) return null; + if(parsed.type===2&&macKey&&parsed.mac){ + var macData=concatBytes(parsed.iv,parsed.ct); var computedMac=await hmacSha256(macKey,macData); + var match=true; if(computedMac.length!==parsed.mac.length) match=false; + else{ for(var i=0;i0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); } + async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } } + + function selectedCount(){ var n=0; for(var k in state.selectedMap){ if(state.selectedMap[k]) n++; } return n; } + function filteredCiphers(){ var out=[]; for(var i=0;i' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('brand')+'
    ' + + '
    '+t('subtitle')+'
    ' + + '
    ' + + renderMsg() + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + ' ' + + (state.pendingLogin ? '' + + '

    '+t('totpVerify')+'

    '+t('totpVerifySub')+'
    ' + + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') + + '
    ' + + '
    ' + : '') + + '
    ' + + '
    '; + } + + function renderRegisterScreen(){ + return '' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('register')+'
    ' + + '
    '+t('brand')+'
    ' + + '
    ' + + renderMsg() + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + ' ' + + '
    ' + + '
    '; + } + + function renderVaultTab(){ + var list=filteredCiphers(); + var rows=''; + for(var i=0;i
    '+esc(nameText)+'
    '+esc(c.id)+'
    '; + } + if(!rows) rows='
    '+t('noItems')+'
    '; + + var c0=selectedCipher(); + var detail='
    '+t('selectItem')+'
    '; + if(c0){ + var login = c0.login||{}; + var fields=Array.isArray(c0.fields)?c0.fields:[]; + var fh=''; + for(var j=0;j '+esc(fields[j].decValue||fields[j].value||'')+''; + var uriHtml=''; if(login.uris){for(var j=0;j '+esc(u.decUri||u.uri||'')+'';}} + var cardHtml=''; if(c0.card){var cd=c0.card; cardHtml='
    Cardholder: '+esc(cd.decCardholderName||cd.cardholderName||'')+'
    Number: '+esc(cd.decNumber||cd.number||'')+'
    Brand: '+esc(cd.decBrand||cd.brand||'')+'
    Exp: '+esc(cd.decExpMonth||cd.expMonth||'')+'/'+esc(cd.decExpYear||cd.expYear||'')+'
    CVV: '+esc(cd.decCode||cd.code||'')+'
    ';} + var identHtml=''; if(c0.identity){var id=c0.identity; identHtml='
    Name: '+esc((id.decFirstName||id.firstName||'')+' '+(id.decLastName||id.lastName||''))+'
    Email: '+esc(id.decEmail||id.email||'')+'
    Phone: '+esc(id.decPhone||id.phone||'')+'
    Company: '+esc(id.decCompany||id.company||'')+'
    Username: '+esc(id.decUsername||id.username||'')+'
    ';} + detail='' + + '
    Name: '+esc(c0.decName||c0.name||'')+'
    ' + + '
    Notes: '+esc(c0.decNotes||c0.notes||'')+'
    ' + + (c0.login?('
    Username: '+esc(login.decUsername||login.username||'')+'
    ' + + '
    Password: '+esc(login.decPassword||login.password||'')+'
    ' + + '
    TOTP: '+esc(login.decTotp||login.totp||'')+'
    '+uriHtml):'' + ) + cardHtml + identHtml + fh; + } + + return '' + + renderMsg() + + '
    ' + + '

    '+t('vault')+'

    ' + + '
    ' + + '
    ' + + '
    '+rows+'
    '+detail+'
    '; + } + + function renderSettingsTab(){ + var p=state.profile||{}; + var secret=currentTotpSecret(); + var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); + return '' + + renderMsg() + + '

    '+t('settings')+'

    ' + + '

    '+t('profile')+'

    ' + + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' + + '

    '+t('totpSetup')+'

    TOTP QR
    Disable action prompts for master password.
    '; + } + function renderTotpDisableModal(){ + if(!state.totpDisableOpen) return ''; + return '' + + '

    '+t('disableTotp')+'

    '+t('totpDisableSub')+'
    ' + + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') + + '
    ' + + '
    '; + } + + function renderHelpTab(){ + return '' + + '

    '+t('help')+'

    ' + + '

    '+t('helpSync')+'

    • '+t('helpSync1')+'
    • '+t('helpSync2')+'
    • '+t('helpSync3')+'
    ' + + '

    '+t('helpErr')+'

    • '+t('helpErr1')+'
    • '+t('helpErr2')+'
    • '+t('helpErr3')+'
    • '+t('helpErr4')+'
    ' + + '

    '+t('helpTb')+'

    • '+t('helpTb1')+'
    • '+t('helpTb2')+'
    • '+t('helpTb3')+'
    • '+t('helpTb4')+'
    '; + } + + function renderAdminTab(){ + var usersRows=''; + for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' + + (canAct?'':'') + + (canAct?' ':'') + + ''; + } + if(!usersRows) usersRows='No users found.'; + + var inviteRows=''; + for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' + + '' + + (inv.status==='active'?' ':'') + + ''; + } + if(!inviteRows) inviteRows='No invites found.'; + + return '' + + renderMsg() + + '
    ' + + '

    '+t('admin')+'

    ' + + '' + + '
    ' + + '

    '+t('createInvite')+'

    ' + + '

    '+t('users')+'

    '+usersRows+'
    '+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
    ' + + '

    '+t('invites')+'

    '+inviteRows+'
    Code'+t('status')+'Expires At'+t('action')+'
    '; + } + + function renderApp(){ + var isAdmin=state.profile&&state.profile.role==='admin'; + var showFolders=state.tab==='vault'; + var folders='' + + ''; + for(var i=0;i'+esc(folderName)+''; } + var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); + + return '' + + '' + + '
    ' + + (showFolders?(' '):'') + + '
    '+content+'
    ' + + '
    '+renderTotpDisableModal(); + } + + function render(){ + if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    '; return; } + if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } + if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } + app.innerHTML=renderApp(); + } + + async function init(){ + var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); + if(state.session){ + try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } + } + state.phase=registered?'login':'register'; render(); + } + + async function onRegister(form){ + clearMsg(); + var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); + state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; + if(!email||!p) return setMsg('Please input email and password.', 'err'); + if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); + if(p!==p2) return setMsg('Passwords do not match.', 'err'); + try{ + var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em); + var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']); + var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); + var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); + var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); + state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; + state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginPassword(form){ + clearMsg(); + var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||''); + if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err'); + try{ + var d=await deriveLoginHash(state.loginEmail,state.loginPassword); + var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access'); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}); + var j=await jsonOrNull(resp); + if(!resp.ok){ + if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; } + return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err'); + } + await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginTotp(form){ + if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err'); + var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; } + var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()}); + var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; } + state.loginTotpError=''; + await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword); + } + + async function onLoginSuccess(tokenJson, masterKey, email, password){ + state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; + await loadProfile(); + try{ + var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); + var symKeyBytes=await decryptBw(state.profile.key,ek,em); + if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } + }catch(e){ console.warn('Key derivation failed:',e); } + await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; + setMsg('Login success.', 'ok'); + } + async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } + async function onChangePassword(form){ + var fd=new FormData(form); + var currentPassword=String(fd.get('currentPassword')||''); + var newPassword=String(fd.get('newPassword')||''); + var newPassword2=String(fd.get('newPassword2')||''); + if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); + if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); + if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); + if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); + var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); + if(!email) return setMsg('Profile email missing.', 'err'); + try{ + var current=await deriveLoginHash(email,currentPassword); + var userSym=buildSymmetricKeyBytes(); + if(!userSym){ + var oldEk=await hkdfExpand(current.masterKey,'enc',32); + var oldEm=await hkdfExpand(current.masterKey,'mac',32); + userSym=await decryptBw(state.profile.key,oldEk,oldEm); + } + if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); + var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); + var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); + var nextEk=await hkdfExpand(nextMasterKey,'enc',32); + var nextEm=await hkdfExpand(nextMasterKey,'mac',32); + var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); + var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); + var j=await jsonOrNull(r); + if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); + logout(); + setMsg('Master password changed. Please log in again.', 'ok'); + }catch(e){ + setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); + } + } + async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } + function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } + async function onDisableTotpSubmit(form){ + var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); + if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; } + try{ + var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword); + var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})}); + var j=await jsonOrNull(r); + if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; } + state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; + render(); setMsg('TOTP disabled.', 'ok'); + }catch(e){ + state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e)); + render(); + } + } + + async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;istate.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } + + async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); } + async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); } + async function onDeleteUser(id){ if(!window.confirm('Delete this user and all user data?')) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); } + async function onRevokeInvite(code){ var r=await authFetch('/api/admin/invites/'+encodeURIComponent(code),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Revoke invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite revoked.', 'ok'); } + + app.addEventListener('submit', function(ev){ + var form=ev.target; if(!(form instanceof HTMLFormElement)) return; ev.preventDefault(); + if(form.id==='registerForm') return void onRegister(form); + if(form.id==='loginForm') return void onLoginPassword(form); + if(form.id==='loginTotpForm') return void onLoginTotp(form); + if(form.id==='profileForm') return void onSaveProfile(form); + if(form.id==='passwordForm') return void onChangePassword(form); + if(form.id==='totpEnableForm') return void onEnableTotp(form); + if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form); + if(form.id==='inviteForm') return void onCreateInvite(form); + }); + + app.addEventListener('click', function(ev){ + var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return; + if(a==='toggle-lang'){ state.lang = state.lang === 'zh' ? 'en' : 'zh'; render(); return; } + if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } + if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } + if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } + if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } + if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } + if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } + if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; var filtered=filteredCiphers(); state.selectedCipherId=filtered.length?filtered[0].id:''; render(); return; } + if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; render(); return; } + if(a==='toggle-select'){ ev.stopPropagation(); state.selectedMap[n.getAttribute('data-id')]=!!n.checked; render(); return; } + if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i Date: Fri, 27 Feb 2026 01:49:04 +0800 Subject: [PATCH 044/149] Enhance styles for app layout and components --- src/webclient/script.ts | 909 ++++++++++++++++++++++++++++++++++++++-- src/webclient/styles.ts | 257 +++++++++++- 2 files changed, 1111 insertions(+), 55 deletions(-) diff --git a/src/webclient/script.ts b/src/webclient/script.ts index ae306f7..4eda343 100644 --- a/src/webclient/script.ts +++ b/src/webclient/script.ts @@ -19,6 +19,20 @@ ciphers: [], folders: [], folderFilterId: '', + vaultQuery: '', + vaultType: 'all', + showSelectedPassword: false, + vaultSearchComposing: false, + vaultSearchTimer: 0, + totpTicking: false, + totpTickBusy: false, + detailMode: 'view', + detailDraft: null, + createMenuOpen: false, + fieldModalOpen: false, + fieldModalType: 'text', + fieldModalLabel: '', + fieldModalValue: '', selectedCipherId: '', selectedMap: {}, users: [], @@ -58,11 +72,34 @@ folders: 'Folders', allItems: 'All Items', noFolder: 'No Folder', - refresh: 'Refresh', + searchVault: 'Search vault', + filter: 'Filter', + typeAll: 'All items', + typeLogin: 'Logins', + typeCard: 'Cards', + typeIdentity: 'Identities', + typeNote: 'Secure notes', + typeOther: 'Other', + addWebsite: '+ Add website', + addField: '+ Add field', + fieldType: 'Field type', + fieldLabel: 'Field label', + fieldValue: 'Field value', + fieldText: 'Text', + fieldHidden: 'Hidden', + fieldBoolean: 'Boolean', + fieldLinked: 'Linked', + add: 'Add', + newTypeLogin: 'Login', + newTypeCard: 'Card', + newTypeIdentity: 'Identity', + newTypeNote: 'Note', + newTypeSsh: 'SSH key', + refresh: 'Sync', move: 'Move', delete: 'Delete', selectAll: 'Select All', - clear: 'Clear', + clear: 'Cancel', noItems: 'There are no items to list.', selectItem: 'Select an item to view details.', profile: 'Profile', @@ -71,10 +108,22 @@ currentPwd: 'Current Master Password', newPwd: 'New Master Password', totpSetup: 'Two-Step Login (TOTP)', + totpLiveIn: 'Refresh in', enableTotp: 'Enable TOTP', disableTotp: 'Disable TOTP', secret: 'Authenticator Key', verifyCode: 'Verification Code', + credentials: 'Login credentials', + autofillOptions: 'Autofill', + itemHistory: 'Item history', + website: 'Website', + folder: 'Folder', + createdAt: 'Created', + updatedAt: 'Last edited', + open: 'Open', + copy: 'Copy', + reveal: 'Reveal', + hide: 'Hide', users: 'Users', invites: 'Invites', createInvite: 'Create Invite', @@ -130,11 +179,34 @@ folders: '文件夹', allItems: '所有项目', noFolder: '无文件夹', - refresh: '刷新', + searchVault: '搜索密码库', + filter: '筛选', + typeAll: '所有项目', + typeLogin: '登录', + typeCard: '支付卡', + typeIdentity: '身份', + typeNote: '备注', + typeOther: '其他', + addWebsite: '+ 添加网站', + addField: '+ 添加字段', + fieldType: '字段类型', + fieldLabel: '字段标签', + fieldValue: '字段值', + fieldText: '文本型', + fieldHidden: '隐藏型', + fieldBoolean: '复选框型', + fieldLinked: '链接型', + add: '添加', + newTypeLogin: '登录', + newTypeCard: '支付卡', + newTypeIdentity: '身份', + newTypeNote: '笔记', + newTypeSsh: 'SSH 密钥', + refresh: '同步', move: '移动', delete: '删除', selectAll: '全选', - clear: '清除', + clear: '取消', noItems: '没有可列出的项目。', selectItem: '选择一个项目以查看详细信息。', profile: '个人资料', @@ -143,10 +215,22 @@ currentPwd: '当前主密码', newPwd: '新主密码', totpSetup: '两步登录 (TOTP)', + totpLiveIn: '刷新剩余', enableTotp: '启用 TOTP', disableTotp: '禁用 TOTP', secret: '身份验证器密钥', verifyCode: '验证码', + credentials: '登录凭据', + autofillOptions: '自动填充', + itemHistory: '项目历史记录', + website: '网站', + folder: '文件夹', + createdAt: '创建于', + updatedAt: '最后编辑', + open: '打开', + copy: '复制', + reveal: '显示', + hide: '隐藏', users: '用户', invites: '邀请', createInvite: '创建邀请', @@ -264,6 +348,17 @@ c.identity.decFirstName=await decryptStr(c.identity.firstName,ek,mk); c.identity.decLastName=await decryptStr(c.identity.lastName,ek,mk); c.identity.decEmail=await decryptStr(c.identity.email,ek,mk); c.identity.decPhone=await decryptStr(c.identity.phone,ek,mk); c.identity.decCompany=await decryptStr(c.identity.company,ek,mk); c.identity.decUsername=await decryptStr(c.identity.username,ek,mk); + c.identity.decTitle=await decryptStr(c.identity.title,ek,mk); c.identity.decMiddleName=await decryptStr(c.identity.middleName,ek,mk); + c.identity.decSsn=await decryptStr(c.identity.ssn,ek,mk); c.identity.decPassportNumber=await decryptStr(c.identity.passportNumber,ek,mk); + c.identity.decLicenseNumber=await decryptStr(c.identity.licenseNumber,ek,mk); c.identity.decAddress1=await decryptStr(c.identity.address1,ek,mk); + c.identity.decAddress2=await decryptStr(c.identity.address2,ek,mk); c.identity.decAddress3=await decryptStr(c.identity.address3,ek,mk); + c.identity.decCity=await decryptStr(c.identity.city,ek,mk); c.identity.decState=await decryptStr(c.identity.state,ek,mk); + c.identity.decPostalCode=await decryptStr(c.identity.postalCode,ek,mk); c.identity.decCountry=await decryptStr(c.identity.country,ek,mk); + } + if(c.sshKey){ + c.sshKey.decPrivateKey=await decryptStr(c.sshKey.privateKey,ek,mk); + c.sshKey.decPublicKey=await decryptStr(c.sshKey.publicKey,ek,mk); + c.sshKey.decFingerprint=await decryptStr(c.sshKey.fingerprint,ek,mk); } if(c.fields){ for(var j=0;j>>0, false); + var key=await crypto.subtle.importKey('raw', keyBytes, {name:'HMAC', hash:'SHA-1'}, false, ['sign']); + var sig=new Uint8Array(await crypto.subtle.sign('HMAC', key, buf)); + var offset=sig[sig.length-1]&0x0f; + var bin=((sig[offset]&0x7f)<<24)|((sig[offset+1]&0xff)<<16)|((sig[offset+2]&0xff)<<8)|(sig[offset+3]&0xff); + var token=String(bin%1000000).padStart(6,'0'); + return { token: token.slice(0,3)+' '+token.slice(3), remain: remain }; + } + async function updateLiveTotpDisplay(){ + if(state.phase!=='app'||state.tab!=='vault') return; + if(state.totpTickBusy) return; + var vEl=document.getElementById('totp-live-value'); + var rEl=document.getElementById('totp-live-remain'); + if(!vEl||!rEl) return; + var c=selectedCipher(); if(!c||!c.login) return; + var raw=(c.login.decTotp||c.login.totp||'').trim(); + if(!raw){ vEl.textContent=''; rEl.textContent=''; return; } + state.totpTickBusy=true; + try{ + var x=await calcTotpNow(raw); + if(!x){ vEl.textContent='N/A'; rEl.textContent=''; return; } + vEl.textContent=x.token; + rEl.textContent=t('totpLiveIn')+': '+x.remain+'s'; + }catch(e){ + vEl.textContent='N/A'; + rEl.textContent=''; + }finally{ + state.totpTickBusy=false; + } + } + function ensureTotpTicker(){ + if(state.totpTicking) return; + state.totpTicking=true; + setInterval(function(){ updateLiveTotpDisplay(); }, 1000); + } + function filteredCiphers(){ + var out=[]; var q=String(state.vaultQuery||'').toLowerCase(); + for(var i=0;i=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key }; + }catch(e){} + } + return { enc: user.enc, mac: user.mac, key: null }; + } + async function encryptTextValue(v, enc, mac){ + var s=String(v==null?'':v); + if(!s) return null; + return encryptBw(new TextEncoder().encode(s), enc, mac); + } + function openCreateDraft(){ + state.detailMode='create'; + state.showSelectedPassword=true; + state.createMenuOpen=false; + state.detailDraft={ + id: '', + type: 1, + name: '', + folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'', + reprompt: false, + loginUsername: '', + loginPassword: '', + loginTotp: '', + websites: [''], + cardholderName: '', + cardNumber: '', + cardBrand: '', + cardExpMonth: '', + cardExpYear: '', + cardCode: '', + identTitle: '', + identFirstName: '', + identMiddleName: '', + identLastName: '', + identUsername: '', + identCompany: '', + identSsn: '', + identPassportNumber: '', + identLicenseNumber: '', + identEmail: '', + identPhone: '', + identAddress1: '', + identAddress2: '', + identAddress3: '', + identCity: '', + identState: '', + identPostalCode: '', + identCountry: '', + sshPrivateKey: '', + sshPublicKey: '', + sshFingerprint: '', + customFields: [], + notes: '' + }; + } + function openEditDraft(cipher){ + if(!cipher) return; + var login=cipher.login||{}; + var uris=Array.isArray(login.uris)?login.uris:[]; + var ws=[]; for(var i=0;i'+l+''; } + return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked')); + } + function renderCreateMenu(){ + if(!state.createMenuOpen) return ''; + return '
    '; + } + function renderFieldModal(){ + if(!state.fieldModalOpen) return ''; + return '' + + '

    '+t('addField')+'

    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    '; + } + function fieldTypeTextByNum(n){ + var x=parseFieldType(n); + if(x===1) return t('fieldHidden'); + if(x===2) return t('fieldBoolean'); + if(x===3) return t('fieldLinked'); + return t('fieldText'); + } + function renderCardBrandOptions(selected){ + var s=String(selected||'').toLowerCase(); + var brands=['','visa','mastercard','amex','discover','jcb','unionpay','dinersclub','maestro']; + var labels={ '':'-- Select --', visa:'Visa', mastercard:'Mastercard', amex:'American Express', discover:'Discover', jcb:'JCB', unionpay:'UnionPay', dinersclub:'Diners Club', maestro:'Maestro' }; + var out=''; + for(var i=0;i'+labels[b]+''; + } + return out; + } + function renderMonthOptions(selected){ + var s=String(selected||''); + var out=''; + for(var m=1;m<=12;m++){ + var mm=m<10?('0'+m):String(m); + out += ''; + } + return out; + } + function renderDraftTypeCards(d){ + var typeNum=Number(d&&d.type||1); + if(typeNum===3){ + return '' + + '
    Card details
    ' + + '
    Cardholder name
    ' + + '
    Number
    ' + + '
    Brand
    ' + + '
    Exp month
    Exp year
    ' + + '
    Security code (CVV)
    ' + + '
    '; + } + if(typeNum===4){ + return '' + + '
    Personal details
    ' + + '
    Title
    ' + + '
    First name
    ' + + '
    Middle name
    ' + + '
    Last name
    ' + + '
    Username
    ' + + '
    Company
    ' + + '
    ' + + '
    Identity
    ' + + '
    SSN
    ' + + '
    Passport number
    ' + + '
    License number
    ' + + '
    ' + + '
    Contact information
    ' + + '
    Email
    ' + + '
    Phone
    ' + + '
    ' + + '
    Address
    ' + + '
    Address 1
    ' + + '
    Address 2
    ' + + '
    Address 3
    ' + + '
    City / Town
    ' + + '
    State / Province
    ' + + '
    ZIP / Postal code
    ' + + '
    Country
    ' + + '
    '; + } + if(typeNum===5){ + return '' + + '
    SSH key
    ' + + '
    Private key
    ' + + '
    Public key
    ' + + '
    Fingerprint
    ' + + '
    '; + } + if(typeNum===2){ + return ''; + } + return '' + + '
    '+t('credentials')+'
    ' + + '
    Username
    ' + + '
    Password
    ' + + '
    TOTP Secret
    ' + + '
    '; + } + function renderReadOnlyCustomFields(cipher){ + var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[]; + if(!fs.length) return ''; + var rows=''; + for(var i=0;i
    '+esc(value||'')+'
    '; + } + return '
    Fields
    '+rows+'
    '; + } + function renderReadOnlyTypeDetails(c0, folderLabel, created, updated){ + var typeNum=Number(c0&&c0.type||1); + var notes=c0&&((c0.decNotes||c0.notes)||''); + var baseHead='' + + '
    ' + + '
    '+esc(c0.decName||c0.name||'')+'
    ' + + '
    '+t('folder')+': '+esc(folderLabel||t('noFolder'))+'
    ' + + '
    '; + var history='' + + '
    '+t('itemHistory')+'
    ' + + '
    '+t('updatedAt')+': '+esc(updated)+'
    ' + + '
    '+t('createdAt')+': '+esc(created)+'
    ' + + '
    '; + if(typeNum===3){ + var c=c0.card||{}; + return baseHead + + '
    Card details
    ' + + '
    Cardholder name
    '+esc(c.decCardholderName||c.cardholderName||'')+'
    ' + + '
    Number
    '+esc(c.decNumber||c.number||'')+'
    ' + + '
    Brand
    '+esc(c.decBrand||c.brand||'')+'
    ' + + '
    Exp month/year
    '+esc((c.decExpMonth||c.expMonth||'')+' / '+(c.decExpYear||c.expYear||''))+'
    ' + + '
    Security code (CVV)
    '+esc(c.decCode||c.code||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===4){ + var id=c0.identity||{}; + return baseHead + + '
    Personal details
    ' + + '
    Title
    '+esc(id.decTitle||id.title||'')+'
    ' + + '
    First name
    '+esc(id.decFirstName||id.firstName||'')+'
    ' + + '
    Middle name
    '+esc(id.decMiddleName||id.middleName||'')+'
    ' + + '
    Last name
    '+esc(id.decLastName||id.lastName||'')+'
    ' + + '
    Username
    '+esc(id.decUsername||id.username||'')+'
    ' + + '
    Company
    '+esc(id.decCompany||id.company||'')+'
    ' + + '
    ' + + '
    Identity
    ' + + '
    SSN
    '+esc(id.decSsn||id.ssn||'')+'
    ' + + '
    Passport number
    '+esc(id.decPassportNumber||id.passportNumber||'')+'
    ' + + '
    License number
    '+esc(id.decLicenseNumber||id.licenseNumber||'')+'
    ' + + '
    ' + + '
    Contact information
    ' + + '
    Email
    '+esc(id.decEmail||id.email||'')+'
    ' + + '
    Phone
    '+esc(id.decPhone||id.phone||'')+'
    ' + + '
    ' + + '
    Address
    ' + + '
    Address 1
    '+esc(id.decAddress1||id.address1||'')+'
    ' + + '
    Address 2
    '+esc(id.decAddress2||id.address2||'')+'
    ' + + '
    Address 3
    '+esc(id.decAddress3||id.address3||'')+'
    ' + + '
    City / Town
    '+esc(id.decCity||id.city||'')+'
    ' + + '
    State / Province
    '+esc(id.decState||id.state||'')+'
    ' + + '
    ZIP / Postal code
    '+esc(id.decPostalCode||id.postalCode||'')+'
    ' + + '
    Country
    '+esc(id.decCountry||id.country||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===5){ + var ssh=c0.sshKey||{}; + var privateKey=ssh.decPrivateKey||ssh.privateKey||''; + return baseHead + + '
    SSH key
    ' + + '
    Private key
    '+esc(privateKey?new Array(Math.max(String(privateKey).length,12)+1).join('•'):'')+'
    ' + + '
    Public key
    '+esc(ssh.decPublicKey||ssh.publicKey||'')+'
    ' + + '
    Fingerprint
    '+esc(ssh.decFingerprint||ssh.fingerprint||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===2){ + return baseHead + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + var login=c0.login||{}; + var username=login.decUsername||login.username||''; + var rawPwd=login.decPassword||login.password||''; + var masked=rawPwd?new Array(Math.max(rawPwd.length,12)+1).join('•'):''; + var pwdText=state.showSelectedPassword?rawPwd:masked; + var totp=login.decTotp||login.totp||''; + var uri0=firstCipherUri(c0); + return baseHead + + '
    '+t('credentials')+'
    ' + + '
    Username
    '+esc(username)+'
    ' + + '
    Password
    '+esc(pwdText)+'
    ' + + (totp?('
    TOTP
    ...
    '):'') + + '
    ' + + '
    '+t('autofillOptions')+'
    ' + + '
    '+t('website')+'
    '+esc(uri0||'')+'
    '+(uri0?(''):'')+(uri0?(''):'')+'
    ' + + '
    ' + + renderReadOnlyCustomFields(c0) + + history; + } function renderLoginScreen(){ return '' + '
    ' @@ -375,40 +1080,90 @@ function renderVaultTab(){ var list=filteredCiphers(); + function renderFolderOptions(selectedId){ + var html=''; + for(var fi=0;fi'+esc(ff.decName||ff.name||ff.id)+''; + } + return html; + } var rows=''; for(var i=0;i
    '+esc(nameText)+'
    '+esc(c.id)+'
    '; + var subtitle=listSubtitle(c); + var uri=firstCipherUri(c); + var host=hostFromUri(uri); + var icon=host + ? ('') + : '🌐'; + rows += '' + + '
    ' + + '' + + '
    '+icon+'
    '+esc(nameText)+'
    '+esc(subtitle||'')+'
    ' + + '
    '; } - if(!rows) rows='
    '+t('noItems')+'
    '; + if(!rows) rows='
    '+t('noItems')+'
    '; var c0=selectedCipher(); - var detail='
    '+t('selectItem')+'
    '; - if(c0){ - var login = c0.login||{}; - var fields=Array.isArray(c0.fields)?c0.fields:[]; - var fh=''; - for(var j=0;j '+esc(fields[j].decValue||fields[j].value||'')+''; - var uriHtml=''; if(login.uris){for(var j=0;j '+esc(u.decUri||u.uri||'')+'';}} - var cardHtml=''; if(c0.card){var cd=c0.card; cardHtml='
    Cardholder: '+esc(cd.decCardholderName||cd.cardholderName||'')+'
    Number: '+esc(cd.decNumber||cd.number||'')+'
    Brand: '+esc(cd.decBrand||cd.brand||'')+'
    Exp: '+esc(cd.decExpMonth||cd.expMonth||'')+'/'+esc(cd.decExpYear||cd.expYear||'')+'
    CVV: '+esc(cd.decCode||cd.code||'')+'
    ';} - var identHtml=''; if(c0.identity){var id=c0.identity; identHtml='
    Name: '+esc((id.decFirstName||id.firstName||'')+' '+(id.decLastName||id.lastName||''))+'
    Email: '+esc(id.decEmail||id.email||'')+'
    Phone: '+esc(id.decPhone||id.phone||'')+'
    Company: '+esc(id.decCompany||id.company||'')+'
    Username: '+esc(id.decUsername||id.username||'')+'
    ';} + var detail='
    '+t('selectItem')+'
    '; + if(state.detailMode==='create'){ + var dc=state.detailDraft||{}; + var wsHtml=''; var cws=Array.isArray(dc.websites)?dc.websites:['']; + for(var wci=0;wci'+(cws.length>1?'':'')+''; + } + var cfHtml=''; var cfs=Array.isArray(dc.customFields)?dc.customFields:[]; + for(var cfi=0;cfi
    '+esc(cf.value||'')+'
    '; + } detail='' - + '
    Name: '+esc(c0.decName||c0.name||'')+'
    ' - + '
    Notes: '+esc(c0.decNotes||c0.notes||'')+'
    ' - + (c0.login?('
    Username: '+esc(login.decUsername||login.username||'')+'
    ' - + '
    Password: '+esc(login.decPassword||login.password||'')+'
    ' - + '
    TOTP: '+esc(login.decTotp||login.totp||'')+'
    '+uriHtml):'' - ) + cardHtml + identHtml + fh; + + '
    '+t('folder')+':
    ' + + renderDraftTypeCards(dc) + + (Number(dc.type||1)===1?('
    '+t('autofillOptions')+'
    '+wsHtml+'' + + '
    '):'') + + '
    Additional options
    ' + + '' + + '
    ' + + '
    ' + + '
    Fields
    '+cfHtml+'
    ' + + '
    '; + } else if(c0){ + var folderLabel=c0.folderId?folderNameById(c0.folderId):t('noFolder'); + var updated=c0.revisionDate||c0.updatedAt||''; + var created=c0.creationDate||c0.createdAt||''; + if(state.detailMode==='edit'){ + var de=state.detailDraft||{}; + var ewsHtml=''; var ews=Array.isArray(de.websites)?de.websites:['']; + for(var wei=0;wei'+(ews.length>1?'':'')+''; + } + var efsHtml=''; var efs=Array.isArray(de.customFields)?de.customFields:[]; + for(var efi=0;efi
    '+esc(ef.value||'')+'
    '; + } + detail='' + + '
    '+t('folder')+':
    ' + + renderDraftTypeCards(de) + + (Number(de.type||1)===1?('
    '+t('autofillOptions')+'
    '+ewsHtml+'' + + '
    '):'') + + '
    Additional options
    ' + + '' + + '
    ' + + '
    ' + + '
    Fields
    '+efsHtml+'
    ' + + '
    '; + } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated) + + '
    '; } return '' + renderMsg() - + '
    ' - + '

    '+t('vault')+'

    ' - + '
    ' - + '
    ' - + '
    '+rows+'
    '+detail+'
    '; + + '
    '+renderCreateMenu()+'
    '+rows+'
    '+detail+renderFieldModal()+'
    '; } function renderSettingsTab(){ @@ -474,9 +1229,17 @@ function renderApp(){ var isAdmin=state.profile&&state.profile.role==='admin'; var showFolders=state.tab==='vault'; - var folders='' - + ''; - for(var i=0;i'+esc(folderName)+''; } + var folders='' + + '' + + ''; + for(var i=0;i📁'+esc(folderName)+''; } + var typeTree='' + + '' + + '' + + '' + + '' + + '' + + ''; var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); return '' @@ -495,7 +1258,11 @@ + ' ' + '' + '
    ' - + (showFolders?(' '):'') + + (showFolders?(' '):'') + '
    '+content+'
    ' + '
    '+renderTotpDisableModal(); } @@ -505,10 +1272,12 @@ if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } app.innerHTML=renderApp(); + updateLiveTotpDisplay(); } async function init(){ var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + ensureTotpTicker(); var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); if(state.session){ try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } @@ -654,11 +1423,35 @@ if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } - if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; var filtered=filteredCiphers(); state.selectedCipherId=filtered.length?filtered[0].id:''; render(); return; } - if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; render(); return; } + if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } + if(a==='type-filter'){ state.vaultType=n.getAttribute('data-type')||'all'; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } + if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; state.showSelectedPassword=false; render(); return; } + if(a==='detail-create-toggle'){ state.createMenuOpen=!state.createMenuOpen; render(); return; } + if(a==='detail-create-type'){ openCreateDraft(); if(state.detailDraft) state.detailDraft.type=Number(n.getAttribute('data-type')||1); render(); return; } + if(a==='detail-edit'){ openEditDraft(selectedCipher()); render(); return; } + if(a==='detail-cancel'){ closeDetailEdit(); render(); return; } + if(a==='detail-save'){ return void saveDetailDraft(); } + if(a==='detail-delete'){ return void deleteSelectedCipher(); } + if(a==='draft-website-add'){ if(state.detailDraft){ if(!Array.isArray(state.detailDraft.websites)) state.detailDraft.websites=[]; state.detailDraft.websites.push(''); render(); } return; } + if(a==='draft-website-remove'){ if(state.detailDraft&&Array.isArray(state.detailDraft.websites)){ var wi=Number(n.getAttribute('data-index')||-1); if(wi>=0&&wi=0&&fi=0&&wi Date: Fri, 27 Feb 2026 01:56:32 +0800 Subject: [PATCH 045/149] Add runtime configuration loader and styles for web application --- public/index.html | 14 ++++++++++ src/webclient/script.ts => public/web/app.js | 27 ++++++++----------- public/web/runtime-config.js | 15 +++++++++++ .../styles.ts => public/web/styles.css | 3 +-- src/handlers/web.ts | 9 ------- src/router.ts | 13 ++++----- src/webclient/page.ts | 25 ----------------- wrangler.toml | 1 + 8 files changed, 49 insertions(+), 58 deletions(-) create mode 100644 public/index.html rename src/webclient/script.ts => public/web/app.js (98%) create mode 100644 public/web/runtime-config.js rename src/webclient/styles.ts => public/web/styles.css (99%) delete mode 100644 src/handlers/web.ts delete mode 100644 src/webclient/page.ts diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4b4f505 --- /dev/null +++ b/public/index.html @@ -0,0 +1,14 @@ + + + + + + NodeWarden Web + + + +
    + + + + diff --git a/src/webclient/script.ts b/public/web/app.js similarity index 98% rename from src/webclient/script.ts rename to public/web/app.js index 4eda343..d28ca4d 100644 --- a/src/webclient/script.ts +++ b/public/web/app.js @@ -1,8 +1,7 @@ -export function renderWebClientScript(defaultKdfIterations: number): string { - return ` -(function () { + +export function startNodewardenApp(runtimeConfig) { var app = document.getElementById('app'); - var defaultKdfIterations = ${defaultKdfIterations}; + var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000; var state = { phase: 'loading', lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', @@ -429,7 +428,7 @@ function hostFromUri(uri){ try{ if(!uri) return ''; - var fixed=/^https?:\\/\\//i.test(uri)?uri:('https://'+uri); + var fixed=/^https?:\/\//i.test(uri)?uri:('https://'+uri); return new URL(fixed).hostname; }catch(e){ return ''; } } @@ -452,7 +451,7 @@ function extractTotpSecret(raw){ var s=String(raw||'').trim(); if(!s) return ''; - if(/^otpauth:\\/\\//i.test(s)){ + if(/^otpauth:\/\//i.test(s)){ try{ var u=new URL(s); var sec=u.searchParams.get('secret'); @@ -1097,7 +1096,7 @@ var uri=firstCipherUri(c); var host=hostFromUri(uri); var icon=host - ? ('') + ? ('') : '🌐'; rows += '' + '
    ' @@ -1376,7 +1375,7 @@ setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); } } - async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } + async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } async function onDisableTotpSubmit(form){ var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); @@ -1395,7 +1394,7 @@ } async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;istate.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } + async function onBulkMove(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var opts=['0) No folder']; for(var i=0;istate.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); } async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); } @@ -1449,9 +1448,9 @@ if(a==='select-all'){ var list=filteredCiphers(); state.selectedMap={}; for(var i=0;i { - void request; - void env; - return htmlResponse(renderWebClientHTML()); -} diff --git a/src/router.ts b/src/router.ts index 6a2b472..b3139a7 100644 --- a/src/router.ts +++ b/src/router.ts @@ -49,7 +49,6 @@ import { handleSync } from './handlers/sync'; // Setup handlers import { handleSetupStatus } from './handlers/setup'; -import { handleWebClientPage } from './handlers/web'; import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; // Import handler @@ -186,16 +185,18 @@ export async function handleRequest(request: Request, env: Env): Promise - - - - - NodeWarden Web - - - -
    - - -`; -} diff --git a/wrangler.toml b/wrangler.toml index 6d578b5..e6c8961 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,7 @@ name = "nodewarden" main = "src/index.ts" compatibility_date = "2024-01-01" +assets = { directory = "./public", not_found_handling = "single-page-application", run_worker_first = ["/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*", "/favicon.ico", "/favicon.svg"] } # D1 Database for storing vault data [[d1_databases]] From ceb4bef9e401719683c7d7267b6c3b7aceca5f9e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 02:05:40 +0800 Subject: [PATCH 046/149] Add vault-utils.js with utility functions for field type parsing, selection counting, cipher type mapping, URI handling, and extracting first cipher URI --- public/web/app.js | 1518 +------------------------------------ public/web/crypto.js | 135 ++++ public/web/i18n.js | 217 ++++++ public/web/main.js | 1183 +++++++++++++++++++++++++++++ public/web/vault-utils.js | 44 ++ 5 files changed, 1580 insertions(+), 1517 deletions(-) create mode 100644 public/web/crypto.js create mode 100644 public/web/i18n.js create mode 100644 public/web/main.js create mode 100644 public/web/vault-utils.js diff --git a/public/web/app.js b/public/web/app.js index d28ca4d..1dad3d1 100644 --- a/public/web/app.js +++ b/public/web/app.js @@ -1,1518 +1,2 @@ +export { startNodewardenApp } from './main.js'; -export function startNodewardenApp(runtimeConfig) { - var app = document.getElementById('app'); - var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000; - var state = { - phase: 'loading', - lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', - msg: '', - msgType: 'ok', - inviteCode: '', - registerName: '', - registerEmail: '', - registerPassword: '', - registerPassword2: '', - session: null, - profile: null, - tab: 'vault', - ciphers: [], - folders: [], - folderFilterId: '', - vaultQuery: '', - vaultType: 'all', - showSelectedPassword: false, - vaultSearchComposing: false, - vaultSearchTimer: 0, - totpTicking: false, - totpTickBusy: false, - detailMode: 'view', - detailDraft: null, - createMenuOpen: false, - fieldModalOpen: false, - fieldModalType: 'text', - fieldModalLabel: '', - fieldModalValue: '', - selectedCipherId: '', - selectedMap: {}, - users: [], - invites: [], - loginEmail: '', - loginPassword: '', - loginTotpToken: '', - loginTotpError: '', - pendingLogin: null, - totpSetupSecret: '', - totpSetupToken: '', - totpDisableOpen: false, - totpDisablePassword: '', - totpDisableError: '' - }; - var NO_FOLDER_FILTER = '__none__'; - - var i18n = { - en: { - brand: 'NodeWarden', - subtitle: 'Open Source Password Manager', - login: 'Log In', - register: 'Create Account', - email: 'Email Address', - masterPwd: 'Master Password', - confirmPwd: 'Confirm Master Password', - name: 'Name', - inviteCode: 'Invite Code (Optional)', - loginBtn: 'Log In', - registerBtn: 'Create Account', - backToLogin: 'Back to Log In', - vault: 'Vault', - settings: 'Settings', - admin: 'Admin', - help: 'Help', - logout: 'Log Out', - folders: 'Folders', - allItems: 'All Items', - noFolder: 'No Folder', - searchVault: 'Search vault', - filter: 'Filter', - typeAll: 'All items', - typeLogin: 'Logins', - typeCard: 'Cards', - typeIdentity: 'Identities', - typeNote: 'Secure notes', - typeOther: 'Other', - addWebsite: '+ Add website', - addField: '+ Add field', - fieldType: 'Field type', - fieldLabel: 'Field label', - fieldValue: 'Field value', - fieldText: 'Text', - fieldHidden: 'Hidden', - fieldBoolean: 'Boolean', - fieldLinked: 'Linked', - add: 'Add', - newTypeLogin: 'Login', - newTypeCard: 'Card', - newTypeIdentity: 'Identity', - newTypeNote: 'Note', - newTypeSsh: 'SSH key', - refresh: 'Sync', - move: 'Move', - delete: 'Delete', - selectAll: 'Select All', - clear: 'Cancel', - noItems: 'There are no items to list.', - selectItem: 'Select an item to view details.', - profile: 'Profile', - saveProfile: 'Save Profile', - changePwd: 'Change Master Password', - currentPwd: 'Current Master Password', - newPwd: 'New Master Password', - totpSetup: 'Two-Step Login (TOTP)', - totpLiveIn: 'Refresh in', - enableTotp: 'Enable TOTP', - disableTotp: 'Disable TOTP', - secret: 'Authenticator Key', - verifyCode: 'Verification Code', - credentials: 'Login credentials', - autofillOptions: 'Autofill', - itemHistory: 'Item history', - website: 'Website', - folder: 'Folder', - createdAt: 'Created', - updatedAt: 'Last edited', - open: 'Open', - copy: 'Copy', - reveal: 'Reveal', - hide: 'Hide', - users: 'Users', - invites: 'Invites', - createInvite: 'Create Invite', - expiresIn: 'Expires in (hours)', - copyLink: 'Copy Link', - revoke: 'Revoke', - ban: 'Ban', - unban: 'Unban', - status: 'Status', - role: 'Role', - action: 'Options', - loading: 'Loading NodeWarden...', - totpVerify: 'Two-step verification', - totpVerifySub: 'Password is already verified.', - totpCode: 'TOTP Code', - verify: 'Verify', - cancel: 'Cancel', - totpDisableSub: 'Enter master password to disable two-step verification.', - helpSync: 'Upstream Sync', - helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).', - helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.', - helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.', - helpErr: 'Common Errors', - helpErr1: '401 Unauthorized: token expired or revoked, login again.', - helpErr2: '403 Account disabled: admin must unban user in User Management.', - helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.', - helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.', - helpTb: 'Troubleshooting', - helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.', - helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.', - helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.', - helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.', - langSwitch: '中文' - }, - zh: { - brand: 'NodeWarden', - subtitle: '开源密码管理器', - login: '登录', - register: '创建账号', - email: '电子邮件地址', - masterPwd: '主密码', - confirmPwd: '确认主密码', - name: '姓名', - inviteCode: '邀请码 (可选)', - loginBtn: '登录', - registerBtn: '创建账号', - backToLogin: '返回登录', - vault: '密码库', - settings: '设置', - admin: '管理', - help: '帮助', - logout: '退出登录', - folders: '文件夹', - allItems: '所有项目', - noFolder: '无文件夹', - searchVault: '搜索密码库', - filter: '筛选', - typeAll: '所有项目', - typeLogin: '登录', - typeCard: '支付卡', - typeIdentity: '身份', - typeNote: '备注', - typeOther: '其他', - addWebsite: '+ 添加网站', - addField: '+ 添加字段', - fieldType: '字段类型', - fieldLabel: '字段标签', - fieldValue: '字段值', - fieldText: '文本型', - fieldHidden: '隐藏型', - fieldBoolean: '复选框型', - fieldLinked: '链接型', - add: '添加', - newTypeLogin: '登录', - newTypeCard: '支付卡', - newTypeIdentity: '身份', - newTypeNote: '笔记', - newTypeSsh: 'SSH 密钥', - refresh: '同步', - move: '移动', - delete: '删除', - selectAll: '全选', - clear: '取消', - noItems: '没有可列出的项目。', - selectItem: '选择一个项目以查看详细信息。', - profile: '个人资料', - saveProfile: '保存个人资料', - changePwd: '更改主密码', - currentPwd: '当前主密码', - newPwd: '新主密码', - totpSetup: '两步登录 (TOTP)', - totpLiveIn: '刷新剩余', - enableTotp: '启用 TOTP', - disableTotp: '禁用 TOTP', - secret: '身份验证器密钥', - verifyCode: '验证码', - credentials: '登录凭据', - autofillOptions: '自动填充', - itemHistory: '项目历史记录', - website: '网站', - folder: '文件夹', - createdAt: '创建于', - updatedAt: '最后编辑', - open: '打开', - copy: '复制', - reveal: '显示', - hide: '隐藏', - users: '用户', - invites: '邀请', - createInvite: '创建邀请', - expiresIn: '过期时间 (小时)', - copyLink: '复制链接', - revoke: '撤销', - ban: '封禁', - unban: '解封', - status: '状态', - role: '角色', - action: '选项', - loading: '正在加载 NodeWarden...', - totpVerify: '两步验证', - totpVerifySub: '密码已验证。', - totpCode: 'TOTP 验证码', - verify: '验证', - cancel: '取消', - totpDisableSub: '输入主密码以禁用两步验证。', - helpSync: '上游同步', - helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。', - helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。', - helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。', - helpErr: '常见错误', - helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。', - helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。', - helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。', - helpErr4: '429 请求过多:等待重试时间,避免突发写入。', - helpTb: '故障排除', - helpTb1: '登录成功但显示加密值:验证个人资料密钥和 KDF 设置是否一致。', - helpTb2: 'TOTP 反复失败:同步设备时间并使用最新密钥重新扫描二维码。', - helpTb3: '密码更改失败:确保当前密码正确且新密码至少 12 个字符。', - helpTb4: '同步冲突:刷新密码库并一次重试一个操作。', - langSwitch: 'English' - } - }; - - function t(key) { return i18n[state.lang][key] || key; } - - function esc(v) { - return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); - } - function sessionKey() { return 'nodewarden.web.session.v2'; } - function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } - function clearMsg() { state.msg = ''; } - function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } - function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } - function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } - function bytesToBase64(bytes) { var s=''; for (var i=0;i=0){ type=parseInt(s.substring(0,dotIdx),10); rest=s.substring(dotIdx+1); } - else{ var pp=s.split('|'); type=(pp.length===3)?2:0; rest=s; } - var parts=rest.split('|'); - if(type===2&&parts.length===3) return {type:2,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:base64ToBytes(parts[2])}; - if((type===0||type===1||type===4)&&parts.length>=2) return {type:type,iv:base64ToBytes(parts[0]),ct:base64ToBytes(parts[1]),mac:null}; - return null; - } - async function decryptAesCbc(data,key,iv){ var ck=await crypto.subtle.importKey('raw',key,{name:'AES-CBC'},false,['decrypt']); return new Uint8Array(await crypto.subtle.decrypt({name:'AES-CBC',iv:iv},ck,data)); } - async function decryptBw(cipherString,encKey,macKey){ - var parsed=parseCipherString(cipherString); if(!parsed) return null; - if(parsed.type===2&&macKey&&parsed.mac){ - var macData=concatBytes(parsed.iv,parsed.ct); var computedMac=await hmacSha256(macKey,macData); - var match=true; if(computedMac.length!==parsed.mac.length) match=false; - else{ for(var i=0;i0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); } - async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } } - - function selectedCount(){ var n=0; for(var k in state.selectedMap){ if(state.selectedMap[k]) n++; } return n; } - function cipherTypeKey(c){ - var tnum=Number(c&&c.type); - if(tnum===1) return 'login'; - if(tnum===3) return 'card'; - if(tnum===4) return 'identity'; - if(tnum===2) return 'note'; - return 'other'; - } - function cipherTypeLabel(c){ - var k=cipherTypeKey(c); - if(k==='login') return t('typeLogin'); - if(k==='card') return t('typeCard'); - if(k==='identity') return t('typeIdentity'); - if(k==='note') return t('typeNote'); - return t('typeOther'); - } - function folderNameById(id){ - for(var i=0;i>>0, false); - var key=await crypto.subtle.importKey('raw', keyBytes, {name:'HMAC', hash:'SHA-1'}, false, ['sign']); - var sig=new Uint8Array(await crypto.subtle.sign('HMAC', key, buf)); - var offset=sig[sig.length-1]&0x0f; - var bin=((sig[offset]&0x7f)<<24)|((sig[offset+1]&0xff)<<16)|((sig[offset+2]&0xff)<<8)|(sig[offset+3]&0xff); - var token=String(bin%1000000).padStart(6,'0'); - return { token: token.slice(0,3)+' '+token.slice(3), remain: remain }; - } - async function updateLiveTotpDisplay(){ - if(state.phase!=='app'||state.tab!=='vault') return; - if(state.totpTickBusy) return; - var vEl=document.getElementById('totp-live-value'); - var rEl=document.getElementById('totp-live-remain'); - if(!vEl||!rEl) return; - var c=selectedCipher(); if(!c||!c.login) return; - var raw=(c.login.decTotp||c.login.totp||'').trim(); - if(!raw){ vEl.textContent=''; rEl.textContent=''; return; } - state.totpTickBusy=true; - try{ - var x=await calcTotpNow(raw); - if(!x){ vEl.textContent='N/A'; rEl.textContent=''; return; } - vEl.textContent=x.token; - rEl.textContent=t('totpLiveIn')+': '+x.remain+'s'; - }catch(e){ - vEl.textContent='N/A'; - rEl.textContent=''; - }finally{ - state.totpTickBusy=false; - } - } - function ensureTotpTicker(){ - if(state.totpTicking) return; - state.totpTicking=true; - setInterval(function(){ updateLiveTotpDisplay(); }, 1000); - } - function filteredCiphers(){ - var out=[]; var q=String(state.vaultQuery||'').toLowerCase(); - for(var i=0;i=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key }; - }catch(e){} - } - return { enc: user.enc, mac: user.mac, key: null }; - } - async function encryptTextValue(v, enc, mac){ - var s=String(v==null?'':v); - if(!s) return null; - return encryptBw(new TextEncoder().encode(s), enc, mac); - } - function openCreateDraft(){ - state.detailMode='create'; - state.showSelectedPassword=true; - state.createMenuOpen=false; - state.detailDraft={ - id: '', - type: 1, - name: '', - folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'', - reprompt: false, - loginUsername: '', - loginPassword: '', - loginTotp: '', - websites: [''], - cardholderName: '', - cardNumber: '', - cardBrand: '', - cardExpMonth: '', - cardExpYear: '', - cardCode: '', - identTitle: '', - identFirstName: '', - identMiddleName: '', - identLastName: '', - identUsername: '', - identCompany: '', - identSsn: '', - identPassportNumber: '', - identLicenseNumber: '', - identEmail: '', - identPhone: '', - identAddress1: '', - identAddress2: '', - identAddress3: '', - identCity: '', - identState: '', - identPostalCode: '', - identCountry: '', - sshPrivateKey: '', - sshPublicKey: '', - sshFingerprint: '', - customFields: [], - notes: '' - }; - } - function openEditDraft(cipher){ - if(!cipher) return; - var login=cipher.login||{}; - var uris=Array.isArray(login.uris)?login.uris:[]; - var ws=[]; for(var i=0;i'+l+''; } - return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked')); - } - function renderCreateMenu(){ - if(!state.createMenuOpen) return ''; - return '
    '; - } - function renderFieldModal(){ - if(!state.fieldModalOpen) return ''; - return '' - + '

    '+t('addField')+'

    ' - + '
    ' - + '
    ' - + '
    ' - + '
    ' - + '
    '; - } - function fieldTypeTextByNum(n){ - var x=parseFieldType(n); - if(x===1) return t('fieldHidden'); - if(x===2) return t('fieldBoolean'); - if(x===3) return t('fieldLinked'); - return t('fieldText'); - } - function renderCardBrandOptions(selected){ - var s=String(selected||'').toLowerCase(); - var brands=['','visa','mastercard','amex','discover','jcb','unionpay','dinersclub','maestro']; - var labels={ '':'-- Select --', visa:'Visa', mastercard:'Mastercard', amex:'American Express', discover:'Discover', jcb:'JCB', unionpay:'UnionPay', dinersclub:'Diners Club', maestro:'Maestro' }; - var out=''; - for(var i=0;i'+labels[b]+''; - } - return out; - } - function renderMonthOptions(selected){ - var s=String(selected||''); - var out=''; - for(var m=1;m<=12;m++){ - var mm=m<10?('0'+m):String(m); - out += ''; - } - return out; - } - function renderDraftTypeCards(d){ - var typeNum=Number(d&&d.type||1); - if(typeNum===3){ - return '' - + '
    Card details
    ' - + '
    Cardholder name
    ' - + '
    Number
    ' - + '
    Brand
    ' - + '
    Exp month
    Exp year
    ' - + '
    Security code (CVV)
    ' - + '
    '; - } - if(typeNum===4){ - return '' - + '
    Personal details
    ' - + '
    Title
    ' - + '
    First name
    ' - + '
    Middle name
    ' - + '
    Last name
    ' - + '
    Username
    ' - + '
    Company
    ' - + '
    ' - + '
    Identity
    ' - + '
    SSN
    ' - + '
    Passport number
    ' - + '
    License number
    ' - + '
    ' - + '
    Contact information
    ' - + '
    Email
    ' - + '
    Phone
    ' - + '
    ' - + '
    Address
    ' - + '
    Address 1
    ' - + '
    Address 2
    ' - + '
    Address 3
    ' - + '
    City / Town
    ' - + '
    State / Province
    ' - + '
    ZIP / Postal code
    ' - + '
    Country
    ' - + '
    '; - } - if(typeNum===5){ - return '' - + '
    SSH key
    ' - + '
    Private key
    ' - + '
    Public key
    ' - + '
    Fingerprint
    ' - + '
    '; - } - if(typeNum===2){ - return ''; - } - return '' - + '
    '+t('credentials')+'
    ' - + '
    Username
    ' - + '
    Password
    ' - + '
    TOTP Secret
    ' - + '
    '; - } - function renderReadOnlyCustomFields(cipher){ - var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[]; - if(!fs.length) return ''; - var rows=''; - for(var i=0;i
    '+esc(value||'')+'
    '; - } - return '
    Fields
    '+rows+'
    '; - } - function renderReadOnlyTypeDetails(c0, folderLabel, created, updated){ - var typeNum=Number(c0&&c0.type||1); - var notes=c0&&((c0.decNotes||c0.notes)||''); - var baseHead='' - + '
    ' - + '
    '+esc(c0.decName||c0.name||'')+'
    ' - + '
    '+t('folder')+': '+esc(folderLabel||t('noFolder'))+'
    ' - + '
    '; - var history='' - + '
    '+t('itemHistory')+'
    ' - + '
    '+t('updatedAt')+': '+esc(updated)+'
    ' - + '
    '+t('createdAt')+': '+esc(created)+'
    ' - + '
    '; - if(typeNum===3){ - var c=c0.card||{}; - return baseHead - + '
    Card details
    ' - + '
    Cardholder name
    '+esc(c.decCardholderName||c.cardholderName||'')+'
    ' - + '
    Number
    '+esc(c.decNumber||c.number||'')+'
    ' - + '
    Brand
    '+esc(c.decBrand||c.brand||'')+'
    ' - + '
    Exp month/year
    '+esc((c.decExpMonth||c.expMonth||'')+' / '+(c.decExpYear||c.expYear||''))+'
    ' - + '
    Security code (CVV)
    '+esc(c.decCode||c.code||'')+'
    ' - + '
    ' - + '
    Additional options
    Notes
    '+esc(notes)+'
    ' - + renderReadOnlyCustomFields(c0) - + history; - } - if(typeNum===4){ - var id=c0.identity||{}; - return baseHead - + '
    Personal details
    ' - + '
    Title
    '+esc(id.decTitle||id.title||'')+'
    ' - + '
    First name
    '+esc(id.decFirstName||id.firstName||'')+'
    ' - + '
    Middle name
    '+esc(id.decMiddleName||id.middleName||'')+'
    ' - + '
    Last name
    '+esc(id.decLastName||id.lastName||'')+'
    ' - + '
    Username
    '+esc(id.decUsername||id.username||'')+'
    ' - + '
    Company
    '+esc(id.decCompany||id.company||'')+'
    ' - + '
    ' - + '
    Identity
    ' - + '
    SSN
    '+esc(id.decSsn||id.ssn||'')+'
    ' - + '
    Passport number
    '+esc(id.decPassportNumber||id.passportNumber||'')+'
    ' - + '
    License number
    '+esc(id.decLicenseNumber||id.licenseNumber||'')+'
    ' - + '
    ' - + '
    Contact information
    ' - + '
    Email
    '+esc(id.decEmail||id.email||'')+'
    ' - + '
    Phone
    '+esc(id.decPhone||id.phone||'')+'
    ' - + '
    ' - + '
    Address
    ' - + '
    Address 1
    '+esc(id.decAddress1||id.address1||'')+'
    ' - + '
    Address 2
    '+esc(id.decAddress2||id.address2||'')+'
    ' - + '
    Address 3
    '+esc(id.decAddress3||id.address3||'')+'
    ' - + '
    City / Town
    '+esc(id.decCity||id.city||'')+'
    ' - + '
    State / Province
    '+esc(id.decState||id.state||'')+'
    ' - + '
    ZIP / Postal code
    '+esc(id.decPostalCode||id.postalCode||'')+'
    ' - + '
    Country
    '+esc(id.decCountry||id.country||'')+'
    ' - + '
    ' - + '
    Additional options
    Notes
    '+esc(notes)+'
    ' - + renderReadOnlyCustomFields(c0) - + history; - } - if(typeNum===5){ - var ssh=c0.sshKey||{}; - var privateKey=ssh.decPrivateKey||ssh.privateKey||''; - return baseHead - + '
    SSH key
    ' - + '
    Private key
    '+esc(privateKey?new Array(Math.max(String(privateKey).length,12)+1).join('•'):'')+'
    ' - + '
    Public key
    '+esc(ssh.decPublicKey||ssh.publicKey||'')+'
    ' - + '
    Fingerprint
    '+esc(ssh.decFingerprint||ssh.fingerprint||'')+'
    ' - + '
    ' - + '
    Additional options
    Notes
    '+esc(notes)+'
    ' - + renderReadOnlyCustomFields(c0) - + history; - } - if(typeNum===2){ - return baseHead - + '
    Additional options
    Notes
    '+esc(notes)+'
    ' - + renderReadOnlyCustomFields(c0) - + history; - } - var login=c0.login||{}; - var username=login.decUsername||login.username||''; - var rawPwd=login.decPassword||login.password||''; - var masked=rawPwd?new Array(Math.max(rawPwd.length,12)+1).join('•'):''; - var pwdText=state.showSelectedPassword?rawPwd:masked; - var totp=login.decTotp||login.totp||''; - var uri0=firstCipherUri(c0); - return baseHead - + '
    '+t('credentials')+'
    ' - + '
    Username
    '+esc(username)+'
    ' - + '
    Password
    '+esc(pwdText)+'
    ' - + (totp?('
    TOTP
    ...
    '):'') - + '
    ' - + '
    '+t('autofillOptions')+'
    ' - + '
    '+t('website')+'
    '+esc(uri0||'')+'
    '+(uri0?(''):'')+(uri0?(''):'')+'
    ' - + '
    ' - + renderReadOnlyCustomFields(c0) - + history; - } - function renderLoginScreen(){ - return '' - + '
    ' - + '
    '+t('langSwitch')+'
    ' - + '
    ' - + '
    ' - + ' ' - + '
    '+t('brand')+'
    ' - + '
    '+t('subtitle')+'
    ' - + '
    ' - + renderMsg() - + '
    ' - + '
    ' - + '
    ' - + ' ' - + '
    ' - + ' ' - + (state.pendingLogin ? '' - + '

    '+t('totpVerify')+'

    '+t('totpVerifySub')+'
    ' - + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') - + '
    ' - + '
    ' - : '') - + '
    ' - + '
    '; - } - - function renderRegisterScreen(){ - return '' - + '
    ' - + '
    '+t('langSwitch')+'
    ' - + '
    ' - + '
    ' - + ' ' - + '
    '+t('register')+'
    ' - + '
    '+t('brand')+'
    ' - + '
    ' - + renderMsg() - + '
    ' - + '
    ' - + '
    ' - + '
    ' - + '
    ' - + '
    ' - + ' ' - + '
    ' - + ' ' - + '
    ' - + '
    '; - } - - function renderVaultTab(){ - var list=filteredCiphers(); - function renderFolderOptions(selectedId){ - var html=''; - for(var fi=0;fi'+esc(ff.decName||ff.name||ff.id)+''; - } - return html; - } - var rows=''; - for(var i=0;i') - : '🌐'; - rows += '' - + '
    ' - + '' - + '
    '+icon+'
    '+esc(nameText)+'
    '+esc(subtitle||'')+'
    ' - + '
    '; - } - if(!rows) rows='
    '+t('noItems')+'
    '; - - var c0=selectedCipher(); - var detail='
    '+t('selectItem')+'
    '; - if(state.detailMode==='create'){ - var dc=state.detailDraft||{}; - var wsHtml=''; var cws=Array.isArray(dc.websites)?dc.websites:['']; - for(var wci=0;wci'+(cws.length>1?'':'')+''; - } - var cfHtml=''; var cfs=Array.isArray(dc.customFields)?dc.customFields:[]; - for(var cfi=0;cfi
    '+esc(cf.value||'')+'
    '; - } - detail='' - + '
    '+t('folder')+':
    ' - + renderDraftTypeCards(dc) - + (Number(dc.type||1)===1?('
    '+t('autofillOptions')+'
    '+wsHtml+'' - + '
    '):'') - + '
    Additional options
    ' - + '' - + '
    ' - + '
    ' - + '
    Fields
    '+cfHtml+'
    ' - + '
    '; - } else if(c0){ - var folderLabel=c0.folderId?folderNameById(c0.folderId):t('noFolder'); - var updated=c0.revisionDate||c0.updatedAt||''; - var created=c0.creationDate||c0.createdAt||''; - if(state.detailMode==='edit'){ - var de=state.detailDraft||{}; - var ewsHtml=''; var ews=Array.isArray(de.websites)?de.websites:['']; - for(var wei=0;wei'+(ews.length>1?'':'')+''; - } - var efsHtml=''; var efs=Array.isArray(de.customFields)?de.customFields:[]; - for(var efi=0;efi
    '+esc(ef.value||'')+'
    '; - } - detail='' - + '
    '+t('folder')+':
    ' - + renderDraftTypeCards(de) - + (Number(de.type||1)===1?('
    '+t('autofillOptions')+'
    '+ewsHtml+'' - + '
    '):'') - + '
    Additional options
    ' - + '' - + '
    ' - + '
    ' - + '
    Fields
    '+efsHtml+'
    ' - + '
    '; - } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated) - + '
    '; - } - - return '' - + renderMsg() - + '
    '+renderCreateMenu()+'
    '+rows+'
    '+detail+renderFieldModal()+'
    '; - } - - function renderSettingsTab(){ - var p=state.profile||{}; - var secret=currentTotpSecret(); - var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); - return '' - + renderMsg() - + '

    '+t('settings')+'

    ' - + '

    '+t('profile')+'

    ' - + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' - + '

    '+t('totpSetup')+'

    TOTP QR
    Disable action prompts for master password.
    '; - } - function renderTotpDisableModal(){ - if(!state.totpDisableOpen) return ''; - return '' - + '

    '+t('disableTotp')+'

    '+t('totpDisableSub')+'
    ' - + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') - + '
    ' - + '
    '; - } - - function renderHelpTab(){ - return '' - + '

    '+t('help')+'

    ' - + '

    '+t('helpSync')+'

    • '+t('helpSync1')+'
    • '+t('helpSync2')+'
    • '+t('helpSync3')+'
    ' - + '

    '+t('helpErr')+'

    • '+t('helpErr1')+'
    • '+t('helpErr2')+'
    • '+t('helpErr3')+'
    • '+t('helpErr4')+'
    ' - + '

    '+t('helpTb')+'

    • '+t('helpTb1')+'
    • '+t('helpTb2')+'
    • '+t('helpTb3')+'
    • '+t('helpTb4')+'
    '; - } - - function renderAdminTab(){ - var usersRows=''; - for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' - + (canAct?'':'') - + (canAct?' ':'') - + ''; - } - if(!usersRows) usersRows='No users found.'; - - var inviteRows=''; - for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' - + '' - + (inv.status==='active'?' ':'') - + ''; - } - if(!inviteRows) inviteRows='No invites found.'; - - return '' - + renderMsg() - + '
    ' - + '

    '+t('admin')+'

    ' - + '' - + '
    ' - + '

    '+t('createInvite')+'

    ' - + '

    '+t('users')+'

    '+usersRows+'
    '+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
    ' - + '

    '+t('invites')+'

    '+inviteRows+'
    Code'+t('status')+'Expires At'+t('action')+'
    '; - } - - function renderApp(){ - var isAdmin=state.profile&&state.profile.role==='admin'; - var showFolders=state.tab==='vault'; - var folders='' - + '' - + ''; - for(var i=0;i📁'+esc(folderName)+''; } - var typeTree='' - + '' - + '' - + '' - + '' - + '' - + ''; - var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); - - return '' - + '' - + '
    ' - + (showFolders?(' '):'') - + '
    '+content+'
    ' - + '
    '+renderTotpDisableModal(); - } - - function render(){ - if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    '; return; } - if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } - if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } - app.innerHTML=renderApp(); - updateLiveTotpDisplay(); - } - - async function init(){ - var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); - ensureTotpTicker(); - var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); - if(state.session){ - try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } - } - state.phase=registered?'login':'register'; render(); - } - - async function onRegister(form){ - clearMsg(); - var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); - state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; - if(!email||!p) return setMsg('Please input email and password.', 'err'); - if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); - if(p!==p2) return setMsg('Passwords do not match.', 'err'); - try{ - var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em); - var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']); - var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); - var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); - var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); - state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; - state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); - }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } - } - - async function onLoginPassword(form){ - clearMsg(); - var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||''); - if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err'); - try{ - var d=await deriveLoginHash(state.loginEmail,state.loginPassword); - var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access'); - var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}); - var j=await jsonOrNull(resp); - if(!resp.ok){ - if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; } - return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err'); - } - await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword); - }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } - } - - async function onLoginTotp(form){ - if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err'); - var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; } - var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken); - var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()}); - var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; } - state.loginTotpError=''; - await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword); - } - - async function onLoginSuccess(tokenJson, masterKey, email, password){ - state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; - await loadProfile(); - try{ - var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); - var symKeyBytes=await decryptBw(state.profile.key,ek,em); - if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } - }catch(e){ console.warn('Key derivation failed:',e); } - await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; - setMsg('Login success.', 'ok'); - } - async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } - async function onChangePassword(form){ - var fd=new FormData(form); - var currentPassword=String(fd.get('currentPassword')||''); - var newPassword=String(fd.get('newPassword')||''); - var newPassword2=String(fd.get('newPassword2')||''); - if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); - if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); - if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); - if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); - var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); - if(!email) return setMsg('Profile email missing.', 'err'); - try{ - var current=await deriveLoginHash(email,currentPassword); - var userSym=buildSymmetricKeyBytes(); - if(!userSym){ - var oldEk=await hkdfExpand(current.masterKey,'enc',32); - var oldEm=await hkdfExpand(current.masterKey,'mac',32); - userSym=await decryptBw(state.profile.key,oldEk,oldEm); - } - if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); - var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); - var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); - var nextEk=await hkdfExpand(nextMasterKey,'enc',32); - var nextEm=await hkdfExpand(nextMasterKey,'mac',32); - var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); - var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); - var j=await jsonOrNull(r); - if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); - logout(); - setMsg('Master password changed. Please log in again.', 'ok'); - }catch(e){ - setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); - } - } - async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } - function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } - async function onDisableTotpSubmit(form){ - var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); - if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; } - try{ - var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword); - var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})}); - var j=await jsonOrNull(r); - if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; } - state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; - render(); setMsg('TOTP disabled.', 'ok'); - }catch(e){ - state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e)); - render(); - } - } - - async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;istate.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } - - async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); } - async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); } - async function onDeleteUser(id){ if(!window.confirm('Delete this user and all user data?')) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); } - async function onRevokeInvite(code){ var r=await authFetch('/api/admin/invites/'+encodeURIComponent(code),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Revoke invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite revoked.', 'ok'); } - - app.addEventListener('submit', function(ev){ - var form=ev.target; if(!(form instanceof HTMLFormElement)) return; ev.preventDefault(); - if(form.id==='registerForm') return void onRegister(form); - if(form.id==='loginForm') return void onLoginPassword(form); - if(form.id==='loginTotpForm') return void onLoginTotp(form); - if(form.id==='profileForm') return void onSaveProfile(form); - if(form.id==='passwordForm') return void onChangePassword(form); - if(form.id==='totpEnableForm') return void onEnableTotp(form); - if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form); - if(form.id==='inviteForm') return void onCreateInvite(form); - }); - - app.addEventListener('click', function(ev){ - var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return; - if(a==='toggle-lang'){ state.lang = state.lang === 'zh' ? 'en' : 'zh'; render(); return; } - if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } - if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } - if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } - if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } - if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } - if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } - if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } - if(a==='type-filter'){ state.vaultType=n.getAttribute('data-type')||'all'; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } - if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; state.showSelectedPassword=false; render(); return; } - if(a==='detail-create-toggle'){ state.createMenuOpen=!state.createMenuOpen; render(); return; } - if(a==='detail-create-type'){ openCreateDraft(); if(state.detailDraft) state.detailDraft.type=Number(n.getAttribute('data-type')||1); render(); return; } - if(a==='detail-edit'){ openEditDraft(selectedCipher()); render(); return; } - if(a==='detail-cancel'){ closeDetailEdit(); render(); return; } - if(a==='detail-save'){ return void saveDetailDraft(); } - if(a==='detail-delete'){ return void deleteSelectedCipher(); } - if(a==='draft-website-add'){ if(state.detailDraft){ if(!Array.isArray(state.detailDraft.websites)) state.detailDraft.websites=[]; state.detailDraft.websites.push(''); render(); } return; } - if(a==='draft-website-remove'){ if(state.detailDraft&&Array.isArray(state.detailDraft.websites)){ var wi=Number(n.getAttribute('data-index')||-1); if(wi>=0&&wi=0&&fi=0&&wi= 2) return { type: type, iv: base64ToBytes(parts[0]), ct: base64ToBytes(parts[1]), mac: null }; + throw new Error('unsupported enc type or format'); +} + +export async function decryptBw(cipherString, encKey, macKey) { + var parsed = parseCipherString(cipherString); + if (parsed.type === 2 && macKey && parsed.mac) { + var expect = await hmacSha256(macKey, concatBytes(parsed.iv, parsed.ct)); + if (bytesToBase64(expect) !== bytesToBase64(parsed.mac)) throw new Error('MAC mismatch'); + } + return decryptAesCbc(parsed.ct, encKey, parsed.iv); +} + +export async function decryptStr(cipherString, encKey, macKey) { + if (!cipherString || typeof cipherString !== 'string') return ''; + var plain = await decryptBw(cipherString, encKey, macKey); + return new TextDecoder().decode(plain); +} + +export function extractTotpSecret(raw) { + if (!raw) return ''; + var s = String(raw).trim(); + if (!s) return ''; + if (/^otpauth:\/\//i.test(s)) { + try { + var u = new URL(s); + var qp = u.searchParams.get('secret') || ''; + return qp.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); + } catch (_) {} + } + return s.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); +} + +export function base32ToBytes(input) { + var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + var clean = String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); + var bits = 0, value = 0, out = []; + for (var i = 0; i < clean.length; i++) { + var idx = alphabet.indexOf(clean.charAt(i)); + if (idx < 0) continue; + value = (value << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((value >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return new Uint8Array(out); +} + +export async function calcTotpNow(rawSecret) { + var secret = extractTotpSecret(rawSecret); + if (!secret) return null; + var keyBytes = base32ToBytes(secret); + if (!keyBytes.length) return null; + var step = 30; + var epoch = Math.floor(Date.now() / 1000); + var counter = Math.floor(epoch / step); + var remain = step - (epoch % step); + var msg = new Uint8Array(8); + var c = counter; + for (var i = 7; i >= 0; i--) { msg[i] = c & 0xff; c = Math.floor(c / 256); } + var key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']); + var hs = new Uint8Array(await crypto.subtle.sign('HMAC', key, msg)); + var off = hs[hs.length - 1] & 0x0f; + var bin = ((hs[off] & 0x7f) << 24) | ((hs[off + 1] & 0xff) << 16) | ((hs[off + 2] & 0xff) << 8) | (hs[off + 3] & 0xff); + var code = (bin % 1000000).toString().padStart(6, '0'); + return { code: code, remain: remain }; +} + diff --git a/public/web/i18n.js b/public/web/i18n.js new file mode 100644 index 0000000..711e546 --- /dev/null +++ b/public/web/i18n.js @@ -0,0 +1,217 @@ +export const I18N = { + en: { + brand: 'NodeWarden', + subtitle: 'Open Source Password Manager', + login: 'Log In', + register: 'Create Account', + email: 'Email Address', + masterPwd: 'Master Password', + confirmPwd: 'Confirm Master Password', + name: 'Name', + inviteCode: 'Invite Code (Optional)', + loginBtn: 'Log In', + registerBtn: 'Create Account', + backToLogin: 'Back to Log In', + vault: 'Vault', + settings: 'Settings', + admin: 'Admin', + help: 'Help', + logout: 'Log Out', + folders: 'Folders', + allItems: 'All Items', + noFolder: 'No Folder', + searchVault: 'Search vault', + filter: 'Filter', + typeAll: 'All items', + typeLogin: 'Logins', + typeCard: 'Cards', + typeIdentity: 'Identities', + typeNote: 'Secure notes', + typeOther: 'Other', + addWebsite: '+ Add website', + addField: '+ Add field', + fieldType: 'Field type', + fieldLabel: 'Field label', + fieldValue: 'Field value', + fieldText: 'Text', + fieldHidden: 'Hidden', + fieldBoolean: 'Boolean', + fieldLinked: 'Linked', + add: 'Add', + newTypeLogin: 'Login', + newTypeCard: 'Card', + newTypeIdentity: 'Identity', + newTypeNote: 'Note', + newTypeSsh: 'SSH key', + refresh: 'Sync', + move: 'Move', + delete: 'Delete', + selectAll: 'Select All', + clear: 'Cancel', + noItems: 'There are no items to list.', + selectItem: 'Select an item to view details.', + profile: 'Profile', + saveProfile: 'Save Profile', + changePwd: 'Change Master Password', + currentPwd: 'Current Master Password', + newPwd: 'New Master Password', + totpSetup: 'Two-Step Login (TOTP)', + totpLiveIn: 'Refresh in', + enableTotp: 'Enable TOTP', + disableTotp: 'Disable TOTP', + secret: 'Authenticator Key', + verifyCode: 'Verification Code', + credentials: 'Login credentials', + autofillOptions: 'Autofill', + itemHistory: 'Item history', + website: 'Website', + folder: 'Folder', + createdAt: 'Created', + updatedAt: 'Last edited', + open: 'Open', + copy: 'Copy', + reveal: 'Reveal', + hide: 'Hide', + users: 'Users', + invites: 'Invites', + createInvite: 'Create Invite', + expiresIn: 'Expires in (hours)', + copyLink: 'Copy Link', + revoke: 'Revoke', + ban: 'Ban', + unban: 'Unban', + status: 'Status', + role: 'Role', + action: 'Options', + loading: 'Loading NodeWarden...', + totpVerify: 'Two-step verification', + totpVerifySub: 'Password is already verified.', + totpCode: 'TOTP Code', + verify: 'Verify', + cancel: 'Cancel', + totpDisableSub: 'Enter master password to disable two-step verification.', + helpSync: 'Upstream Sync', + helpSync1: 'Track upstream with a fork and scheduled sync workflow (recommended).', + helpSync2: 'Before merge: compare API routes, migration files, and auth logic changes.', + helpSync3: 'After merge: run local dev migration tests, then deploy Worker after validation.', + helpErr: 'Common Errors', + helpErr1: '401 Unauthorized: token expired or revoked, login again.', + helpErr2: '403 Account disabled: admin must unban user in User Management.', + helpErr3: '403 Invite invalid: invite expired/used/revoked, create a new invite.', + helpErr4: '429 Too many requests: wait retry seconds and avoid burst writes.', + helpTb: 'Troubleshooting', + helpTb1: 'Login OK but encrypted values shown: verify profile key and KDF settings are consistent.', + helpTb2: 'TOTP fails repeatedly: sync device time and re-scan QR using latest secret.', + helpTb3: 'Password change failed: ensure current password is correct and new password has at least 12 chars.', + helpTb4: 'Sync conflicts: refresh vault and retry one operation at a time.', + langSwitch: '中文', + }, + zh: { + brand: 'NodeWarden', + subtitle: '开源密码管理器', + login: '登录', + register: '创建账号', + email: '电子邮件地址', + masterPwd: '主密码', + confirmPwd: '确认主密码', + name: '姓名', + inviteCode: '邀请码 (可选)', + loginBtn: '登录', + registerBtn: '创建账号', + backToLogin: '返回登录', + vault: '密码库', + settings: '设置', + admin: '管理', + help: '帮助', + logout: '退出登录', + folders: '文件夹', + allItems: '所有项目', + noFolder: '无文件夹', + searchVault: '搜索密码库', + filter: '筛选', + typeAll: '所有项目', + typeLogin: '登录', + typeCard: '支付卡', + typeIdentity: '身份', + typeNote: '备注', + typeOther: '其他', + addWebsite: '+ 添加网站', + addField: '+ 添加字段', + fieldType: '字段类型', + fieldLabel: '字段标签', + fieldValue: '字段值', + fieldText: '文本型', + fieldHidden: '隐藏型', + fieldBoolean: '复选框型', + fieldLinked: '链接型', + add: '添加', + newTypeLogin: '登录', + newTypeCard: '支付卡', + newTypeIdentity: '身份', + newTypeNote: '笔记', + newTypeSsh: 'SSH 密钥', + refresh: '同步', + move: '移动', + delete: '删除', + selectAll: '全选', + clear: '取消', + noItems: '没有可列出的项目。', + selectItem: '选择一个项目以查看详细信息。', + profile: '个人资料', + saveProfile: '保存个人资料', + changePwd: '更改主密码', + currentPwd: '当前主密码', + newPwd: '新主密码', + totpSetup: '两步登录 (TOTP)', + totpLiveIn: '刷新剩余', + enableTotp: '启用 TOTP', + disableTotp: '禁用 TOTP', + secret: '身份验证器密钥', + verifyCode: '验证码', + credentials: '登录凭据', + autofillOptions: '自动填充', + itemHistory: '项目历史记录', + website: '网站', + folder: '文件夹', + createdAt: '创建于', + updatedAt: '最后编辑', + open: '打开', + copy: '复制', + reveal: '显示', + hide: '隐藏', + users: '用户', + invites: '邀请', + createInvite: '创建邀请', + expiresIn: '过期时间 (小时)', + copyLink: '复制链接', + revoke: '撤销', + ban: '封禁', + unban: '解封', + status: '状态', + role: '角色', + action: '选项', + loading: '正在加载 NodeWarden...', + totpVerify: '两步验证', + totpVerifySub: '密码已验证。', + totpCode: 'TOTP 验证码', + verify: '验证', + cancel: '取消', + totpDisableSub: '输入主密码以禁用两步验证。', + helpSync: '上游同步', + helpSync1: '建议通过 fork 和定时同步工作流跟踪上游。', + helpSync2: '合并前:比较 API 路由、迁移文件和认证逻辑的更改。', + helpSync3: '合并后:运行本地开发迁移测试,验证后部署 Worker。', + helpErr: '常见错误', + helpErr1: '401 未授权:令牌过期或被撤销,请重新登录。', + helpErr2: '403 账号被禁用:管理员必须在用户管理中解封用户。', + helpErr3: '403 邀请无效:邀请已过期/已使用/被撤销,请创建新邀请。', + helpErr4: '429 请求过多:等待重试时间,避免突发写入。', + helpTb: '排障指南', + helpTb1: '登录成功但显示密文:检查 profile key 和 KDF 参数是否一致。', + helpTb2: 'TOTP 持续失败:同步设备时间并使用最新密钥重新扫码。', + helpTb3: '修改密码失败:确认当前密码正确且新密码至少 12 位。', + helpTb4: '同步冲突:先刷新密码库,再逐个操作重试。', + langSwitch: 'English', + }, +}; + diff --git a/public/web/main.js b/public/web/main.js new file mode 100644 index 0000000..8e96fc5 --- /dev/null +++ b/public/web/main.js @@ -0,0 +1,1183 @@ +import { I18N } from './i18n.js'; +import { bytesToBase64, base64ToBytes, concatBytes, pbkdf2, hkdfExpand, encryptBw, decryptBw, decryptStr, extractTotpSecret, calcTotpNow } from './crypto.js'; +import { parseFieldType as parseFieldTypeUtil, selectedCount as selectedCountUtil, cipherTypeKey as cipherTypeKeyUtil, firstCipherUri as firstCipherUriUtil, hostFromUri as hostFromUriUtil } from './vault-utils.js'; + +export function startNodewardenApp(runtimeConfig) { + var app = document.getElementById('app'); + var defaultKdfIterations = Number(runtimeConfig && runtimeConfig.defaultKdfIterations) || 600000; + var state = { + phase: 'loading', + lang: (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en', + msg: '', + msgType: 'ok', + inviteCode: '', + registerName: '', + registerEmail: '', + registerPassword: '', + registerPassword2: '', + session: null, + profile: null, + tab: 'vault', + ciphers: [], + folders: [], + folderFilterId: '', + vaultQuery: '', + vaultType: 'all', + showSelectedPassword: false, + vaultSearchComposing: false, + vaultSearchTimer: 0, + totpTicking: false, + totpTickBusy: false, + detailMode: 'view', + detailDraft: null, + createMenuOpen: false, + fieldModalOpen: false, + fieldModalType: 'text', + fieldModalLabel: '', + fieldModalValue: '', + selectedCipherId: '', + selectedMap: {}, + users: [], + invites: [], + loginEmail: '', + loginPassword: '', + loginTotpToken: '', + loginTotpError: '', + pendingLogin: null, + totpSetupSecret: '', + totpSetupToken: '', + totpDisableOpen: false, + totpDisablePassword: '', + totpDisableError: '' + }; + var NO_FOLDER_FILTER = '__none__'; + var i18n = I18N; + + function t(key) { return i18n[state.lang][key] || key; } + + function esc(v) { + return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + function sessionKey() { return 'nodewarden.web.session.v2'; } + function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } + function clearMsg() { state.msg = ''; } + function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } + function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } + function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } + async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } } + async function decryptVault(){ + if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return; + var encKey=base64ToBytes(state.session.symEncKey); var macKey=base64ToBytes(state.session.symMacKey); + for(var i=0;i0) state.selectedCipherId=state.ciphers[0].id; await decryptVault(); } + async function loadAdminData(){ if(!state.profile||state.profile.role!=='admin') return; var u=await authFetch('/api/admin/users',{method:'GET'}); if(u.ok){ var uj=await u.json(); state.users=uj.data||[]; } var i=await authFetch('/api/admin/invites?includeInactive=true',{method:'GET'}); if(i.ok){ var ij=await i.json(); state.invites=ij.data||[]; } } + + function selectedCount(){ return selectedCountUtil(state.selectedMap); } + function cipherTypeKey(c){ return cipherTypeKeyUtil(c); } + function cipherTypeLabel(c){ + var k=cipherTypeKey(c); + if(k==='login') return t('typeLogin'); + if(k==='card') return t('typeCard'); + if(k==='identity') return t('typeIdentity'); + if(k==='note') return t('typeNote'); + return t('typeOther'); + } + function folderNameById(id){ + for(var i=0;i=64) return { enc: raw.slice(0,32), mac: raw.slice(32,64), key: cipher.key }; + }catch(e){} + } + return { enc: user.enc, mac: user.mac, key: null }; + } + async function encryptTextValue(v, enc, mac){ + var s=String(v==null?'':v); + if(!s) return null; + return encryptBw(new TextEncoder().encode(s), enc, mac); + } + function openCreateDraft(){ + state.detailMode='create'; + state.showSelectedPassword=true; + state.createMenuOpen=false; + state.detailDraft={ + id: '', + type: 1, + name: '', + folderId: state.folderFilterId&&state.folderFilterId!==NO_FOLDER_FILTER?state.folderFilterId:'', + reprompt: false, + loginUsername: '', + loginPassword: '', + loginTotp: '', + websites: [''], + cardholderName: '', + cardNumber: '', + cardBrand: '', + cardExpMonth: '', + cardExpYear: '', + cardCode: '', + identTitle: '', + identFirstName: '', + identMiddleName: '', + identLastName: '', + identUsername: '', + identCompany: '', + identSsn: '', + identPassportNumber: '', + identLicenseNumber: '', + identEmail: '', + identPhone: '', + identAddress1: '', + identAddress2: '', + identAddress3: '', + identCity: '', + identState: '', + identPostalCode: '', + identCountry: '', + sshPrivateKey: '', + sshPublicKey: '', + sshFingerprint: '', + customFields: [], + notes: '' + }; + } + function openEditDraft(cipher){ + if(!cipher) return; + var login=cipher.login||{}; + var uris=Array.isArray(login.uris)?login.uris:[]; + var ws=[]; for(var i=0;i'+l+''; } + return opt('text',t('fieldText'))+opt('hidden',t('fieldHidden'))+opt('boolean',t('fieldBoolean'))+opt('linked',t('fieldLinked')); + } + function renderCreateMenu(){ + if(!state.createMenuOpen) return ''; + return '
    '; + } + function renderFieldModal(){ + if(!state.fieldModalOpen) return ''; + return '' + + '

    '+t('addField')+'

    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    '; + } + function fieldTypeTextByNum(n){ + var x=parseFieldType(n); + if(x===1) return t('fieldHidden'); + if(x===2) return t('fieldBoolean'); + if(x===3) return t('fieldLinked'); + return t('fieldText'); + } + function renderCardBrandOptions(selected){ + var s=String(selected||'').toLowerCase(); + var brands=['','visa','mastercard','amex','discover','jcb','unionpay','dinersclub','maestro']; + var labels={ '':'-- Select --', visa:'Visa', mastercard:'Mastercard', amex:'American Express', discover:'Discover', jcb:'JCB', unionpay:'UnionPay', dinersclub:'Diners Club', maestro:'Maestro' }; + var out=''; + for(var i=0;i'+labels[b]+''; + } + return out; + } + function renderMonthOptions(selected){ + var s=String(selected||''); + var out=''; + for(var m=1;m<=12;m++){ + var mm=m<10?('0'+m):String(m); + out += ''; + } + return out; + } + function renderDraftTypeCards(d){ + var typeNum=Number(d&&d.type||1); + if(typeNum===3){ + return '' + + '
    Card details
    ' + + '
    Cardholder name
    ' + + '
    Number
    ' + + '
    Brand
    ' + + '
    Exp month
    Exp year
    ' + + '
    Security code (CVV)
    ' + + '
    '; + } + if(typeNum===4){ + return '' + + '
    Personal details
    ' + + '
    Title
    ' + + '
    First name
    ' + + '
    Middle name
    ' + + '
    Last name
    ' + + '
    Username
    ' + + '
    Company
    ' + + '
    ' + + '
    Identity
    ' + + '
    SSN
    ' + + '
    Passport number
    ' + + '
    License number
    ' + + '
    ' + + '
    Contact information
    ' + + '
    Email
    ' + + '
    Phone
    ' + + '
    ' + + '
    Address
    ' + + '
    Address 1
    ' + + '
    Address 2
    ' + + '
    Address 3
    ' + + '
    City / Town
    ' + + '
    State / Province
    ' + + '
    ZIP / Postal code
    ' + + '
    Country
    ' + + '
    '; + } + if(typeNum===5){ + return '' + + '
    SSH key
    ' + + '
    Private key
    ' + + '
    Public key
    ' + + '
    Fingerprint
    ' + + '
    '; + } + if(typeNum===2){ + return ''; + } + return '' + + '
    '+t('credentials')+'
    ' + + '
    Username
    ' + + '
    Password
    ' + + '
    TOTP Secret
    ' + + '
    '; + } + function renderReadOnlyCustomFields(cipher){ + var fs=Array.isArray(cipher&&cipher.fields)?cipher.fields:[]; + if(!fs.length) return ''; + var rows=''; + for(var i=0;i
    '+esc(value||'')+'
    '; + } + return '
    Fields
    '+rows+'
    '; + } + function renderReadOnlyTypeDetails(c0, folderLabel, created, updated){ + var typeNum=Number(c0&&c0.type||1); + var notes=c0&&((c0.decNotes||c0.notes)||''); + var baseHead='' + + '
    ' + + '
    '+esc(c0.decName||c0.name||'')+'
    ' + + '
    '+t('folder')+': '+esc(folderLabel||t('noFolder'))+'
    ' + + '
    '; + var history='' + + '
    '+t('itemHistory')+'
    ' + + '
    '+t('updatedAt')+': '+esc(updated)+'
    ' + + '
    '+t('createdAt')+': '+esc(created)+'
    ' + + '
    '; + if(typeNum===3){ + var c=c0.card||{}; + return baseHead + + '
    Card details
    ' + + '
    Cardholder name
    '+esc(c.decCardholderName||c.cardholderName||'')+'
    ' + + '
    Number
    '+esc(c.decNumber||c.number||'')+'
    ' + + '
    Brand
    '+esc(c.decBrand||c.brand||'')+'
    ' + + '
    Exp month/year
    '+esc((c.decExpMonth||c.expMonth||'')+' / '+(c.decExpYear||c.expYear||''))+'
    ' + + '
    Security code (CVV)
    '+esc(c.decCode||c.code||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===4){ + var id=c0.identity||{}; + return baseHead + + '
    Personal details
    ' + + '
    Title
    '+esc(id.decTitle||id.title||'')+'
    ' + + '
    First name
    '+esc(id.decFirstName||id.firstName||'')+'
    ' + + '
    Middle name
    '+esc(id.decMiddleName||id.middleName||'')+'
    ' + + '
    Last name
    '+esc(id.decLastName||id.lastName||'')+'
    ' + + '
    Username
    '+esc(id.decUsername||id.username||'')+'
    ' + + '
    Company
    '+esc(id.decCompany||id.company||'')+'
    ' + + '
    ' + + '
    Identity
    ' + + '
    SSN
    '+esc(id.decSsn||id.ssn||'')+'
    ' + + '
    Passport number
    '+esc(id.decPassportNumber||id.passportNumber||'')+'
    ' + + '
    License number
    '+esc(id.decLicenseNumber||id.licenseNumber||'')+'
    ' + + '
    ' + + '
    Contact information
    ' + + '
    Email
    '+esc(id.decEmail||id.email||'')+'
    ' + + '
    Phone
    '+esc(id.decPhone||id.phone||'')+'
    ' + + '
    ' + + '
    Address
    ' + + '
    Address 1
    '+esc(id.decAddress1||id.address1||'')+'
    ' + + '
    Address 2
    '+esc(id.decAddress2||id.address2||'')+'
    ' + + '
    Address 3
    '+esc(id.decAddress3||id.address3||'')+'
    ' + + '
    City / Town
    '+esc(id.decCity||id.city||'')+'
    ' + + '
    State / Province
    '+esc(id.decState||id.state||'')+'
    ' + + '
    ZIP / Postal code
    '+esc(id.decPostalCode||id.postalCode||'')+'
    ' + + '
    Country
    '+esc(id.decCountry||id.country||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===5){ + var ssh=c0.sshKey||{}; + var privateKey=ssh.decPrivateKey||ssh.privateKey||''; + return baseHead + + '
    SSH key
    ' + + '
    Private key
    '+esc(privateKey?new Array(Math.max(String(privateKey).length,12)+1).join('•'):'')+'
    ' + + '
    Public key
    '+esc(ssh.decPublicKey||ssh.publicKey||'')+'
    ' + + '
    Fingerprint
    '+esc(ssh.decFingerprint||ssh.fingerprint||'')+'
    ' + + '
    ' + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + if(typeNum===2){ + return baseHead + + '
    Additional options
    Notes
    '+esc(notes)+'
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + var login=c0.login||{}; + var username=login.decUsername||login.username||''; + var rawPwd=login.decPassword||login.password||''; + var masked=rawPwd?new Array(Math.max(rawPwd.length,12)+1).join('•'):''; + var pwdText=state.showSelectedPassword?rawPwd:masked; + var totp=login.decTotp||login.totp||''; + var uri0=firstCipherUri(c0); + return baseHead + + '
    '+t('credentials')+'
    ' + + '
    Username
    '+esc(username)+'
    ' + + '
    Password
    '+esc(pwdText)+'
    ' + + (totp?('
    TOTP
    ...
    '):'') + + '
    ' + + '
    '+t('autofillOptions')+'
    ' + + '
    '+t('website')+'
    '+esc(uri0||'')+'
    '+(uri0?(''):'')+(uri0?(''):'')+'
    ' + + '
    ' + + renderReadOnlyCustomFields(c0) + + history; + } + function renderLoginScreen(){ + return '' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('brand')+'
    ' + + '
    '+t('subtitle')+'
    ' + + '
    ' + + renderMsg() + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + ' ' + + (state.pendingLogin ? '' + + '

    '+t('totpVerify')+'

    '+t('totpVerifySub')+'
    ' + + (state.loginTotpError?'
    '+esc(state.loginTotpError)+'
    ':'') + + '
    ' + + '
    ' + : '') + + '
    ' + + '
    '; + } + + function renderRegisterScreen(){ + return '' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    '+t('register')+'
    ' + + '
    '+t('brand')+'
    ' + + '
    ' + + renderMsg() + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + ' ' + + '
    ' + + '
    '; + } + + function renderVaultTab(){ + var list=filteredCiphers(); + function renderFolderOptions(selectedId){ + var html=''; + for(var fi=0;fi'+esc(ff.decName||ff.name||ff.id)+''; + } + return html; + } + var rows=''; + for(var i=0;i') + : '🌐'; + rows += '' + + '
    ' + + '' + + '
    '+icon+'
    '+esc(nameText)+'
    '+esc(subtitle||'')+'
    ' + + '
    '; + } + if(!rows) rows='
    '+t('noItems')+'
    '; + + var c0=selectedCipher(); + var detail='
    '+t('selectItem')+'
    '; + if(state.detailMode==='create'){ + var dc=state.detailDraft||{}; + var wsHtml=''; var cws=Array.isArray(dc.websites)?dc.websites:['']; + for(var wci=0;wci'+(cws.length>1?'':'')+''; + } + var cfHtml=''; var cfs=Array.isArray(dc.customFields)?dc.customFields:[]; + for(var cfi=0;cfi
    '+esc(cf.value||'')+'
    '; + } + detail='' + + '
    '+t('folder')+':
    ' + + renderDraftTypeCards(dc) + + (Number(dc.type||1)===1?('
    '+t('autofillOptions')+'
    '+wsHtml+'' + + '
    '):'') + + '
    Additional options
    ' + + '' + + '
    ' + + '
    ' + + '
    Fields
    '+cfHtml+'
    ' + + '
    '; + } else if(c0){ + var folderLabel=c0.folderId?folderNameById(c0.folderId):t('noFolder'); + var updated=c0.revisionDate||c0.updatedAt||''; + var created=c0.creationDate||c0.createdAt||''; + if(state.detailMode==='edit'){ + var de=state.detailDraft||{}; + var ewsHtml=''; var ews=Array.isArray(de.websites)?de.websites:['']; + for(var wei=0;wei'+(ews.length>1?'':'')+''; + } + var efsHtml=''; var efs=Array.isArray(de.customFields)?de.customFields:[]; + for(var efi=0;efi
    '+esc(ef.value||'')+'
    '; + } + detail='' + + '
    '+t('folder')+':
    ' + + renderDraftTypeCards(de) + + (Number(de.type||1)===1?('
    '+t('autofillOptions')+'
    '+ewsHtml+'' + + '
    '):'') + + '
    Additional options
    ' + + '' + + '
    ' + + '
    ' + + '
    Fields
    '+efsHtml+'
    ' + + '
    '; + } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated) + + '
    '; + } + + return '' + + renderMsg() + + '
    '+renderCreateMenu()+'
    '+rows+'
    '+detail+renderFieldModal()+'
    '; + } + + function renderSettingsTab(){ + var p=state.profile||{}; + var secret=currentTotpSecret(); + var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); + return '' + + renderMsg() + + '

    '+t('settings')+'

    ' + + '

    '+t('profile')+'

    ' + + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' + + '

    '+t('totpSetup')+'

    TOTP QR
    Disable action prompts for master password.
    '; + } + function renderTotpDisableModal(){ + if(!state.totpDisableOpen) return ''; + return '' + + '

    '+t('disableTotp')+'

    '+t('totpDisableSub')+'
    ' + + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') + + '
    ' + + '
    '; + } + + function renderHelpTab(){ + return '' + + '

    '+t('help')+'

    ' + + '

    '+t('helpSync')+'

    • '+t('helpSync1')+'
    • '+t('helpSync2')+'
    • '+t('helpSync3')+'
    ' + + '

    '+t('helpErr')+'

    • '+t('helpErr1')+'
    • '+t('helpErr2')+'
    • '+t('helpErr3')+'
    • '+t('helpErr4')+'
    ' + + '

    '+t('helpTb')+'

    • '+t('helpTb1')+'
    • '+t('helpTb2')+'
    • '+t('helpTb3')+'
    • '+t('helpTb4')+'
    '; + } + + function renderAdminTab(){ + var usersRows=''; + for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' + + (canAct?'':'') + + (canAct?' ':'') + + ''; + } + if(!usersRows) usersRows='No users found.'; + + var inviteRows=''; + for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' + + '' + + (inv.status==='active'?' ':'') + + ''; + } + if(!inviteRows) inviteRows='No invites found.'; + + return '' + + renderMsg() + + '
    ' + + '

    '+t('admin')+'

    ' + + '' + + '
    ' + + '

    '+t('createInvite')+'

    ' + + '

    '+t('users')+'

    '+usersRows+'
    '+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
    ' + + '

    '+t('invites')+'

    '+inviteRows+'
    Code'+t('status')+'Expires At'+t('action')+'
    '; + } + + function renderApp(){ + var isAdmin=state.profile&&state.profile.role==='admin'; + var showFolders=state.tab==='vault'; + var folders='' + + '' + + ''; + for(var i=0;i📁'+esc(folderName)+''; } + var typeTree='' + + '' + + '' + + '' + + '' + + '' + + ''; + var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); + + return '' + + '' + + '
    ' + + (showFolders?(' '):'') + + '
    '+content+'
    ' + + '
    '+renderTotpDisableModal(); + } + + function render(){ + if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    '; return; } + if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } + if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } + app.innerHTML=renderApp(); + updateLiveTotpDisplay(); + } + + async function init(){ + var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + ensureTotpTicker(); + var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); + if(state.session){ + try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } + } + state.phase=registered?'login':'register'; render(); + } + + async function onRegister(form){ + clearMsg(); + var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); + state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; + if(!email||!p) return setMsg('Please input email and password.', 'err'); + if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); + if(p!==p2) return setMsg('Passwords do not match.', 'err'); + try{ + var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em); + var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']); + var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); + var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); + var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); + state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; + state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginPassword(form){ + clearMsg(); + var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||''); + if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err'); + try{ + var d=await deriveLoginHash(state.loginEmail,state.loginPassword); + var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access'); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}); + var j=await jsonOrNull(resp); + if(!resp.ok){ + if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; } + return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err'); + } + await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword); + }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } + } + + async function onLoginTotp(form){ + if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err'); + var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; } + var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken); + var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()}); + var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; } + state.loginTotpError=''; + await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword); + } + + async function onLoginSuccess(tokenJson, masterKey, email, password){ + state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; + await loadProfile(); + try{ + var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); + var symKeyBytes=await decryptBw(state.profile.key,ek,em); + if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } + }catch(e){ console.warn('Key derivation failed:',e); } + await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; + setMsg('Login success.', 'ok'); + } + async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } + async function onChangePassword(form){ + var fd=new FormData(form); + var currentPassword=String(fd.get('currentPassword')||''); + var newPassword=String(fd.get('newPassword')||''); + var newPassword2=String(fd.get('newPassword2')||''); + if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); + if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); + if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); + if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); + var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); + if(!email) return setMsg('Profile email missing.', 'err'); + try{ + var current=await deriveLoginHash(email,currentPassword); + var userSym=buildSymmetricKeyBytes(); + if(!userSym){ + var oldEk=await hkdfExpand(current.masterKey,'enc',32); + var oldEm=await hkdfExpand(current.masterKey,'mac',32); + userSym=await decryptBw(state.profile.key,oldEk,oldEm); + } + if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); + var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); + var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); + var nextEk=await hkdfExpand(nextMasterKey,'enc',32); + var nextEm=await hkdfExpand(nextMasterKey,'mac',32); + var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); + var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); + var j=await jsonOrNull(r); + if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); + logout(); + setMsg('Master password changed. Please log in again.', 'ok'); + }catch(e){ + setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); + } + } + async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } + function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } + async function onDisableTotpSubmit(form){ + var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); + if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; } + try{ + var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword); + var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})}); + var j=await jsonOrNull(r); + if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; } + state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; + render(); setMsg('TOTP disabled.', 'ok'); + }catch(e){ + state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e)); + render(); + } + } + + async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); if(!window.confirm('Delete selected '+ids.length+' items?')) return; for(var i=0;istate.folders.length) return setMsg('Invalid folder selection.', 'err'); var folderId=idx===0?null:state.folders[idx-1].id; var r=await authFetch('/api/ciphers/move',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,folderId:folderId})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Bulk move failed.', 'err'); await loadVault(); render(); setMsg('Moved selected items.', 'ok'); } + + async function onCreateInvite(form){ var fd=new FormData(form); var h=Number(fd.get('hours')||168); var r=await authFetch('/api/admin/invites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({expiresInHours:h})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Create invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite created.', 'ok'); } + async function onToggleUserStatus(id,status){ var n=status==='active'?'banned':'active'; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id)+'/status',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:n})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Update user status failed.', 'err'); await loadAdminData(); render(); setMsg('User status updated.', 'ok'); } + async function onDeleteUser(id){ if(!window.confirm('Delete this user and all user data?')) return; var r=await authFetch('/api/admin/users/'+encodeURIComponent(id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete user failed.', 'err'); await loadAdminData(); render(); setMsg('User deleted.', 'ok'); } + async function onRevokeInvite(code){ var r=await authFetch('/api/admin/invites/'+encodeURIComponent(code),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Revoke invite failed.', 'err'); await loadAdminData(); render(); setMsg('Invite revoked.', 'ok'); } + + app.addEventListener('submit', function(ev){ + var form=ev.target; if(!(form instanceof HTMLFormElement)) return; ev.preventDefault(); + if(form.id==='registerForm') return void onRegister(form); + if(form.id==='loginForm') return void onLoginPassword(form); + if(form.id==='loginTotpForm') return void onLoginTotp(form); + if(form.id==='profileForm') return void onSaveProfile(form); + if(form.id==='passwordForm') return void onChangePassword(form); + if(form.id==='totpEnableForm') return void onEnableTotp(form); + if(form.id==='totpDisableForm') return void onDisableTotpSubmit(form); + if(form.id==='inviteForm') return void onCreateInvite(form); + }); + + app.addEventListener('click', function(ev){ + var n=ev.target; while(n&&n!==app&&!n.getAttribute('data-action')) n=n.parentElement; if(!n||n===app) return; var a=n.getAttribute('data-action'); if(!a) return; + if(a==='toggle-lang'){ state.lang = state.lang === 'zh' ? 'en' : 'zh'; render(); return; } + if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } + if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } + if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } + if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } + if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } + if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } + if(a==='folder-filter'){ state.folderFilterId=n.getAttribute('data-folder')||''; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } + if(a==='type-filter'){ state.vaultType=n.getAttribute('data-type')||'all'; syncSelectedWithFilter(); state.showSelectedPassword=false; render(); return; } + if(a==='pick-cipher'){ state.selectedCipherId=n.getAttribute('data-id')||''; state.showSelectedPassword=false; render(); return; } + if(a==='detail-create-toggle'){ state.createMenuOpen=!state.createMenuOpen; render(); return; } + if(a==='detail-create-type'){ openCreateDraft(); if(state.detailDraft) state.detailDraft.type=Number(n.getAttribute('data-type')||1); render(); return; } + if(a==='detail-edit'){ openEditDraft(selectedCipher()); render(); return; } + if(a==='detail-cancel'){ closeDetailEdit(); render(); return; } + if(a==='detail-save'){ return void saveDetailDraft(); } + if(a==='detail-delete'){ return void deleteSelectedCipher(); } + if(a==='draft-website-add'){ if(state.detailDraft){ if(!Array.isArray(state.detailDraft.websites)) state.detailDraft.websites=[]; state.detailDraft.websites.push(''); render(); } return; } + if(a==='draft-website-remove'){ if(state.detailDraft&&Array.isArray(state.detailDraft.websites)){ var wi=Number(n.getAttribute('data-index')||-1); if(wi>=0&&wi=0&&fi=0&&wi Date: Fri, 27 Feb 2026 22:07:37 +0800 Subject: [PATCH 047/149] feat: add QR code generation support and rate limiting for known device probes --- public/index.html | 2 +- public/web/main.js | 23 +++++++++++++++++++-- public/web/runtime-config.js | 14 ++++++++++++- public/web/styles.css | 25 ++++++++++++++++++++++- public/web/vendor/qrcode-generator.min.js | 1 + src/config/limits.ts | 3 +++ src/router.ts | 7 +++++++ src/services/ratelimit.ts | 11 ++++++++++ 8 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 public/web/vendor/qrcode-generator.min.js diff --git a/public/index.html b/public/index.html index 4b4f505..7b1ecd6 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@
    + - diff --git a/public/web/main.js b/public/web/main.js index 8e96fc5..6d8d5b1 100644 --- a/public/web/main.js +++ b/public/web/main.js @@ -214,6 +214,25 @@ export function startNodewardenApp(runtimeConfig) { function randomBase32Secret(len){ var a='ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; var b=crypto.getRandomValues(new Uint8Array(len)); var o=''; for(var i=0;i'; + }catch(_){ + box.innerHTML='
    QR unavailable
    Use secret key below
    '; + } + } function buildSymmetricKeyBytes(){ if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return null; try{ @@ -833,13 +852,12 @@ export function startNodewardenApp(runtimeConfig) { function renderSettingsTab(){ var p=state.profile||{}; var secret=currentTotpSecret(); - var qr='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(buildTotpUri(secret)); return '' + renderMsg() + '

    '+t('settings')+'

    ' + '

    '+t('profile')+'

    ' + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' - + '

    '+t('totpSetup')+'

    TOTP QR
    Disable action prompts for master password.
    '; + + '

    '+t('totpSetup')+'

    QR loading...
    Use secret key below
    Disable action prompts for master password.
    '; } function renderTotpDisableModal(){ if(!state.totpDisableOpen) return ''; @@ -937,6 +955,7 @@ export function startNodewardenApp(runtimeConfig) { if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } app.innerHTML=renderApp(); updateLiveTotpDisplay(); + renderTotpSetupQr(); } async function init(){ diff --git a/public/web/runtime-config.js b/public/web/runtime-config.js index 58f541c..427a7f4 100644 --- a/public/web/runtime-config.js +++ b/public/web/runtime-config.js @@ -1,5 +1,17 @@ import { startNodewardenApp } from './app.js'; +async function ensureQrLibrary() { + if (typeof window.qrcode === 'function') return; + await new Promise((resolve) => { + const s = document.createElement('script'); + s.src = '/web/vendor/qrcode-generator.min.js'; + s.async = true; + s.onload = () => resolve(null); + s.onerror = () => resolve(null); + document.head.appendChild(s); + }); +} + async function loadRuntimeConfig() { try { const resp = await fetch('/api/web/config', { method: 'GET' }); @@ -10,6 +22,6 @@ async function loadRuntimeConfig() { } } +await ensureQrLibrary(); const cfg = await loadRuntimeConfig(); startNodewardenApp(cfg || { defaultKdfIterations: 600000 }); - diff --git a/public/web/styles.css b/public/web/styles.css index a33ae81..7f4845f 100644 --- a/public/web/styles.css +++ b/public/web/styles.css @@ -166,6 +166,30 @@ } .alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; } .alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; } + .totp-qr-card { + background:#fff; + padding:16px; + border:1px solid var(--border-color); + border-radius:8px; + width: 200px; + min-height: 200px; + display:flex; + align-items:center; + justify-content:center; + } + .totp-qr-fallback { + width:100%; + min-height:168px; + border:1px dashed var(--border-color); + border-radius:8px; + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + color:var(--text-secondary); + text-align:center; + padding:8px; + } /* App Layout */ .navbar { @@ -561,4 +585,3 @@ .vault-grid { grid-template-columns: 1fr; } } - diff --git a/public/web/vendor/qrcode-generator.min.js b/public/web/vendor/qrcode-generator.min.js new file mode 100644 index 0000000..1434c6f --- /dev/null +++ b/public/web/vendor/qrcode-generator.min.js @@ -0,0 +1 @@ +var qrcode=function(){function i(t,r){function a(t,r){g=function(t){for(var r=new Array(t),e=0;e>e&1);g[Math.floor(e/3)][e%3+l-8-3]=n}for(e=0;e<18;e+=1){n=!t&&1==(r>>e&1);g[e%3+l-8-3][Math.floor(e/3)]=n}},v=function(t,r){for(var e=f<<3|r,n=B.getBCHTypeInfo(e),o=0;o<15;o+=1){var i=!t&&1==(n>>o&1);o<6?g[o][8]=i:o<8?g[o+1][8]=i:g[l-15+o][8]=i}for(o=0;o<15;o+=1){i=!t&&1==(n>>o&1);o<8?g[8][l-o-1]=i:o<9?g[8][15-o-1+1]=i:g[8][15-o-1]=i}g[l-8][8]=!t},d=function(t,r){for(var e=-1,n=l-1,o=7,i=0,a=B.getMaskFunction(r),u=l-1;0>>o&1)),a(n,u-f)&&(c=!c),g[n][u-f]=c,-1==(o-=1)&&(i+=1,o=7)}if((n+=e)<0||l<=n){n-=e,e=-e;break}}},w=function(t,r,e){for(var n=b.getRSBlocks(t,r),o=M(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+=""},s.createSvgTag=function(t,r,e,n){var o={};"object"==typeof t&&(t=(o=t).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,f,c=s.getModuleCount()*t+2*r,g="";for(f="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",g+=''+p(n.text)+"":"",g+=e.text?''+p(e.text)+"":"",g+='',g+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return s.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*s.getModuleCount()+2*t,u=t,f=a-t,c={"██":"█","█ ":"▀"," █":"▄"," ":" "},g={"██":"▀","█ ":"▀"," █":" "," ":" "},l="";for(r=0;r>>8),r.push(255&o)):r.push(a)}}return r}};var r,t,a=1,u=2,o=4,f=8,y={L:1,M:0,Q:3,H:2},e=0,n=1,c=2,g=3,l=4,h=5,s=6,v=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],(t={}).getBCHTypeInfo=function(t){for(var r=t<<10;0<=d(r)-d(1335);)r^=1335<>>=1;return r}var w=function(){for(var r=new Array(256),e=new Array(256),t=0;t<8;t+=1)r[t]=1<>>8)},writeBytes:function(t,r,e){r=r||0,e=e||t.length;for(var n=0;n>>7-t%8&1)},put:function(t,r){for(var e=0;e>>r-e-1&1))},getLengthInBits:function(){return n},putBit:function(t){var r=Math.floor(n/8);e.length<=r&&e.push(0),t&&(e[r]|=128>>>n%8),n+=1}};return o},x=function(t){var r=a,n=t,e={getMode:function(){return r},getLength:function(t){return n.length},write:function(t){for(var r=n,e=0;e+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e=e.length){if(0==i)return-1;throw"unexpected end of file./"+i}var t=e.charAt(n);if(n+=1,"="==t)return i=0,-1;t.match(/^\s$/)||(o=o<<6|a(t.charCodeAt(0)),i+=6)}var r=o>>>i-8&255;return i-=8,r}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return r},I=function(t,r,e){for(var n=function(t,r){var n=t,o=r,l=new Array(t*r),e={setPixel:function(t,r,e){l[r*n+t]=e},write:function(t){t.writeString("GIF87a"),t.writeShort(n),t.writeShort(o),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(n),t.writeShort(o),t.writeByte(0);var r=i(2);t.writeByte(2);for(var e=0;255>>r!=0)throw"length over";for(;8<=n+r;)e.writeByte(255&(t<>>=8-n,n=o=0;o|=t<>>o-6),o-=6},t.flush=function(){if(0>6,128|63&n):n<55296||57344<=n?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}(function(){return qrcode}); \ No newline at end of file diff --git a/src/config/limits.ts b/src/config/limits.ts index ffcd2d4..341908d 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -35,6 +35,9 @@ // /api/sync read request budget per minute. // /api/sync 读请求每分钟配额。 syncReadRequestsPerMinute: 1000, + // /api/devices/knowndevice probe budget per IP per minute. + // /api/devices/knowndevice 每 IP 每分钟探测配额。 + knownDeviceRequestsPerMinute: 10, // Fixed window size for API rate limiting in seconds. // API 限流固定窗口大小(秒)。 apiWindowSeconds: 60, diff --git a/src/router.ts b/src/router.ts index b3139a7..ec7f498 100644 --- a/src/router.ts +++ b/src/router.ts @@ -235,6 +235,13 @@ export async function handleRequest(request: Request, env: Env): Promise { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.KNOWN_DEVICE_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } } export function getClientIdentifier(request: Request): string { From 4831a0915cc0a8d43b6902bf31cefd5e80892a5f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Fri, 27 Feb 2026 22:39:27 +0800 Subject: [PATCH 048/149] feat: implement vault locking mechanism with auto-lock settings and unlock functionality --- public/web/crypto.js | 23 ++++- public/web/main.js | 191 ++++++++++++++++++++++++++++++++++++++++-- public/web/styles.css | 1 - 3 files changed, 202 insertions(+), 13 deletions(-) diff --git a/public/web/crypto.js b/public/web/crypto.js index 2f4bf75..88130e7 100644 --- a/public/web/crypto.js +++ b/public/web/crypto.js @@ -27,9 +27,25 @@ export async function pbkdf2(passwordOrBytes, saltOrBytes, iterations, keyLen) { } export async function hkdfExpand(prk, info, length) { - var key = await crypto.subtle.importKey('raw', prk, 'HKDF', false, ['deriveBits']); - var bits = await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode(info) }, key, length * 8); - return new Uint8Array(bits); + var enc = new TextEncoder(); + var key = await crypto.subtle.importKey('raw', prk, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + var infoBytes = enc.encode(info || ''); + var result = new Uint8Array(length); + var prev = new Uint8Array(0); + var off = 0; + var cnt = 1; + while (off < length) { + var inp = new Uint8Array(prev.length + infoBytes.length + 1); + inp.set(prev, 0); + inp.set(infoBytes, prev.length); + inp[inp.length - 1] = cnt & 0xff; + prev = new Uint8Array(await crypto.subtle.sign('HMAC', key, inp)); + var c = Math.min(prev.length, length - off); + result.set(prev.slice(0, c), off); + off += c; + cnt += 1; + } + return result; } export async function hmacSha256(keyBytes, dataBytes) { @@ -132,4 +148,3 @@ export async function calcTotpNow(rawSecret) { var code = (bin % 1000000).toString().padStart(6, '0'); return { code: code, remain: remain }; } - diff --git a/public/web/main.js b/public/web/main.js index 6d8d5b1..47dc6fe 100644 --- a/public/web/main.js +++ b/public/web/main.js @@ -48,7 +48,13 @@ export function startNodewardenApp(runtimeConfig) { totpSetupToken: '', totpDisableOpen: false, totpDisablePassword: '', - totpDisableError: '' + totpDisableError: '', + unlockPassword: '', + unlockError: '', + lockTimeoutMinutes: 15, + lockLastActiveTs: Date.now(), + lockCheckTimer: 0, + lockChannel: null }; var NO_FOLDER_FILTER = '__none__'; var i18n = I18N; @@ -59,11 +65,40 @@ export function startNodewardenApp(runtimeConfig) { return String(v == null ? '' : v).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function sessionKey() { return 'nodewarden.web.session.v2'; } + function lockSettingsKey() { return 'nodewarden.web.lock.v1'; } function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } function clearMsg() { state.msg = ''; } function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } - function saveSession() { if (state.session) localStorage.setItem(sessionKey(), JSON.stringify(state.session)); else localStorage.removeItem(sessionKey()); } - function loadSession() { try { var r = localStorage.getItem(sessionKey()); if (!r) return null; var p = JSON.parse(r); if (!p || !p.accessToken || !p.refreshToken) return null; return p; } catch (e) { return null; } } + function saveSession() { + if (!state.session) { localStorage.removeItem(sessionKey()); return; } + var persisted = { + accessToken: state.session.accessToken || '', + refreshToken: state.session.refreshToken || '', + email: state.session.email || '' + }; + localStorage.setItem(sessionKey(), JSON.stringify(persisted)); + } + function loadSession() { + try { + var r = localStorage.getItem(sessionKey()); + if (!r) return null; + var p = JSON.parse(r); + if (!p || !p.accessToken || !p.refreshToken) return null; + return { accessToken: p.accessToken, refreshToken: p.refreshToken, email: p.email || '' }; + } catch (e) { return null; } + } + function saveLockSettings() { + localStorage.setItem(lockSettingsKey(), JSON.stringify({ lockTimeoutMinutes: Number(state.lockTimeoutMinutes) || 0 })); + } + function loadLockSettings() { + try { + var r = localStorage.getItem(lockSettingsKey()); + if (!r) return; + var p = JSON.parse(r); + var mins = Number(p && p.lockTimeoutMinutes); + if (Number.isFinite(mins) && mins >= 0) state.lockTimeoutMinutes = mins; + } catch (_) {} + } async function jsonOrNull(resp){ var t=await resp.text(); if(!t) return null; try{ return JSON.parse(t);} catch(e){ return null; } } async function decryptVault(){ if(!state.session||!state.session.symEncKey||!state.session.symMacKey) return; @@ -112,8 +147,101 @@ export function startNodewardenApp(runtimeConfig) { return { hash: bytesToBase64(h), masterKey: mk, kdfIterations: it }; } + function clearVaultMemory() { + state.ciphers = []; + state.folders = []; + state.folderFilterId = ''; + state.selectedCipherId = ''; + state.selectedMap = {}; + state.detailMode = 'view'; + state.detailDraft = null; + state.showSelectedPassword = false; + } + + function markUserActivity() { + if (state.phase === 'app') state.lockLastActiveTs = Date.now(); + } + + function ensureLockChannel() { + if (state.lockChannel || typeof BroadcastChannel === 'undefined') return; + try { + state.lockChannel = new BroadcastChannel('nodewarden-lock-v1'); + state.lockChannel.onmessage = function (ev) { + var msg = ev && ev.data; + if (!msg || msg.type !== 'lock') return; + if (state.phase === 'app') lockVault(false, false); + }; + } catch (_) {} + } + + function ensureAutoLockTicker() { + if (state.lockCheckTimer) return; + state.lockCheckTimer = setInterval(function () { + if (state.phase !== 'app') return; + var mins = Number(state.lockTimeoutMinutes) || 0; + if (mins <= 0) return; + if ((Date.now() - state.lockLastActiveTs) >= mins * 60 * 1000) { + lockVault(true, true); + } + }, 5000); + } + + function lockVault(showMsg, broadcast) { + if (state.session) { + delete state.session.symEncKey; + delete state.session.symMacKey; + } + clearVaultMemory(); + state.pendingLogin = null; + state.loginTotpToken = ''; + state.loginTotpError = ''; + state.unlockPassword = ''; + state.unlockError = ''; + state.phase = 'locked'; + if (broadcast !== false && state.lockChannel) { + try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {} + } + if (showMsg) setMsg('Vault locked.', 'ok'); + else render(); + } + + async function onUnlock(form) { + clearMsg(); + state.unlockError = ''; + var fd = new FormData(form); + state.unlockPassword = String(fd.get('password') || ''); + if (!state.unlockPassword) { + state.unlockError = 'Please input master password.'; + render(); + return; + } + try { + var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : '').toLowerCase(); + if (!email) throw new Error('email missing'); + var d = await deriveLoginHash(email, state.unlockPassword); + var ek = await hkdfExpand(d.masterKey, 'enc', 32); + var em = await hkdfExpand(d.masterKey, 'mac', 32); + var symKeyBytes = await decryptBw(state.profile.key, ek, em); + if (!symKeyBytes || symKeyBytes.length < 64) throw new Error('invalid key'); + state.session.symEncKey = bytesToBase64(symKeyBytes.slice(0, 32)); + state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64)); + state.unlockPassword = ''; + state.unlockError = ''; + await loadVault(); + await loadAdminData(); + state.phase = 'app'; + state.tab = 'vault'; + state.lockLastActiveTs = Date.now(); + render(); + setMsg('Unlocked.', 'ok'); + } catch (e) { + state.unlockError = 'Unlock failed. Master password is incorrect.'; + render(); + } + } + function logout(){ - state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.phase='login'; saveSession(); clearMsg(); render(); + state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.phase='login'; saveSession(); clearMsg(); render(); } async function authFetch(path, options){ @@ -179,7 +307,7 @@ export function startNodewardenApp(runtimeConfig) { try{ var x=await calcTotpNow(raw); if(!x){ vEl.textContent='N/A'; rEl.textContent=''; return; } - vEl.textContent=x.token; + vEl.textContent=x.code; rEl.textContent=t('totpLiveIn')+': '+x.remain+'s'; }catch(e){ vEl.textContent='N/A'; @@ -761,6 +889,30 @@ export function startNodewardenApp(runtimeConfig) { + ''; } + function renderLockedScreen(){ + var email = String(state.profile && state.profile.email ? state.profile.email : state.session && state.session.email ? state.session.email : ''); + return '' + + '
    ' + + '
    '+t('langSwitch')+'
    ' + + '
    ' + + '
    ' + + ' ' + + '
    Unlock Vault
    ' + + '
    '+esc(email)+'
    ' + + '
    ' + + renderMsg() + + (state.unlockError?('
    '+esc(state.unlockError)+'
    '):'') + + '
    ' + + '
    ' + + ' ' + + '
    ' + + '
    ' + + ' ' + + '
    ' + + '
    ' + + '
    '; + } + function renderVaultTab(){ var list=filteredCiphers(); function renderFolderOptions(selectedId){ @@ -852,9 +1004,11 @@ export function startNodewardenApp(runtimeConfig) { function renderSettingsTab(){ var p=state.profile||{}; var secret=currentTotpSecret(); + var lockMins = Number(state.lockTimeoutMinutes)||0; return '' + renderMsg() + '

    '+t('settings')+'

    ' + + '

    Vault Lock

    ' + '

    '+t('profile')+'

    ' + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' + '

    '+t('totpSetup')+'

    QR loading...
    Use secret key below
    Disable action prompts for master password.
    '; @@ -936,6 +1090,7 @@ export function startNodewardenApp(runtimeConfig) { + ' ' + '' @@ -953,6 +1108,7 @@ export function startNodewardenApp(runtimeConfig) { if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    '; return; } if(state.phase==='register'){ app.innerHTML=renderRegisterScreen(); return; } if(state.phase==='login'){ app.innerHTML=renderLoginScreen(); return; } + if(state.phase==='locked'){ app.innerHTML=renderLockedScreen(); return; } app.innerHTML=renderApp(); updateLiveTotpDisplay(); renderTotpSetupQr(); @@ -960,10 +1116,13 @@ export function startNodewardenApp(runtimeConfig) { async function init(){ var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); + loadLockSettings(); ensureTotpTicker(); + ensureLockChannel(); + ensureAutoLockTicker(); var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); if(state.session){ - try{ await loadProfile(); await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } + try{ await loadProfile(); state.phase='locked'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } } state.phase=registered?'login':'register'; render(); } @@ -1019,11 +1178,20 @@ export function startNodewardenApp(runtimeConfig) { try{ var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); var symKeyBytes=await decryptBw(state.profile.key,ek,em); - if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); saveSession(); } + if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); } }catch(e){ console.warn('Key derivation failed:',e); } - await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; + await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now(); setMsg('Login success.', 'ok'); } + async function onSaveLockSettings(form){ + var fd=new FormData(form); + var mins=Number(fd.get('lockTimeoutMinutes')||0); + if(!Number.isFinite(mins)||mins<0) mins=15; + state.lockTimeoutMinutes=mins; + saveLockSettings(); + state.lockLastActiveTs=Date.now(); + setMsg('Lock settings saved.', 'ok'); + } async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } async function onChangePassword(form){ var fd=new FormData(form); @@ -1090,6 +1258,8 @@ export function startNodewardenApp(runtimeConfig) { if(form.id==='registerForm') return void onRegister(form); if(form.id==='loginForm') return void onLoginPassword(form); if(form.id==='loginTotpForm') return void onLoginTotp(form); + if(form.id==='unlockForm') return void onUnlock(form); + if(form.id==='lockForm') return void onSaveLockSettings(form); if(form.id==='profileForm') return void onSaveProfile(form); if(form.id==='passwordForm') return void onChangePassword(form); if(form.id==='totpEnableForm') return void onEnableTotp(form); @@ -1103,6 +1273,7 @@ export function startNodewardenApp(runtimeConfig) { if(a==='goto-login'){ state.phase='login'; clearMsg(); render(); return; } if(a==='goto-register'){ state.phase='register'; clearMsg(); render(); return; } if(a==='logout'){ if(window.confirm('Log out now?')) logout(); return; } + if(a==='lock'){ lockVault(true, true); return; } if(a==='totp-cancel'){ state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; render(); return; } if(a==='totp-disable-cancel'){ state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; render(); return; } if(a==='tab'){ state.tab=n.getAttribute('data-tab')||'vault'; clearMsg(); render(); return; } @@ -1198,5 +1369,9 @@ export function startNodewardenApp(runtimeConfig) { } }); + ['click','keydown','mousemove','touchstart','scroll'].forEach(function(evt){ + window.addEventListener(evt, markUserActivity, { passive: true }); + }); + init(); } diff --git a/public/web/styles.css b/public/web/styles.css index 7f4845f..b22833b 100644 --- a/public/web/styles.css +++ b/public/web/styles.css @@ -367,7 +367,6 @@ border-radius: 5px; object-fit: contain; flex-shrink: 0; - border: 1px solid #d9dfe8; background: #fff; } .vault-item-icon-wrap { width:24px; height:24px; position:relative; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; } From 7c7d32de3034f60e0cee4aab7d6c4bb9ad3bc4ac Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 28 Feb 2026 00:02:47 +0800 Subject: [PATCH 049/149] feat: add toast notifications and dialog components for improved user interaction --- public/web/i18n.js | 3 +- public/web/main.js | 247 +++++++++++++++++++++++++++++++++--------- public/web/styles.css | 228 +++++++++++++++++++++++++++++++++++++- 3 files changed, 424 insertions(+), 54 deletions(-) diff --git a/public/web/i18n.js b/public/web/i18n.js index 711e546..61e2e14 100644 --- a/public/web/i18n.js +++ b/public/web/i18n.js @@ -21,7 +21,7 @@ export const I18N = { allItems: 'All Items', noFolder: 'No Folder', searchVault: 'Search vault', - filter: 'Filter', + filter: 'Search', typeAll: 'All items', typeLogin: 'Logins', typeCard: 'Cards', @@ -214,4 +214,3 @@ export const I18N = { langSwitch: 'English', }, }; - diff --git a/public/web/main.js b/public/web/main.js index 47dc6fe..63462d1 100644 --- a/public/web/main.js +++ b/public/web/main.js @@ -15,6 +15,8 @@ export function startNodewardenApp(runtimeConfig) { registerEmail: '', registerPassword: '', registerPassword2: '', + registerShowPassword: false, + registerShowPassword2: false, session: null, profile: null, tab: 'vault', @@ -41,6 +43,7 @@ export function startNodewardenApp(runtimeConfig) { invites: [], loginEmail: '', loginPassword: '', + loginShowPassword: false, loginTotpToken: '', loginTotpError: '', pendingLogin: null, @@ -51,10 +54,14 @@ export function startNodewardenApp(runtimeConfig) { totpDisableError: '', unlockPassword: '', unlockError: '', + unlockShowPassword: false, lockTimeoutMinutes: 15, lockLastActiveTs: Date.now(), lockCheckTimer: 0, - lockChannel: null + lockChannel: null, + toasts: [], + toastSeq: 0, + dialog: null }; var NO_FOLDER_FILTER = '__none__'; var i18n = I18N; @@ -66,9 +73,84 @@ export function startNodewardenApp(runtimeConfig) { } function sessionKey() { return 'nodewarden.web.session.v2'; } function lockSettingsKey() { return 'nodewarden.web.lock.v1'; } - function setMsg(t, ty) { state.msg = t || ''; state.msgType = ty || 'ok'; render(); } - function clearMsg() { state.msg = ''; } - function renderMsg() { return state.msg ? '
    ' + esc(state.msg) + '
    ' : ''; } + function dismissToast(id) { + var next = []; + for (var i = 0; i < state.toasts.length; i++) if (state.toasts[i].id !== id) next.push(state.toasts[i]); + if (next.length === state.toasts.length) return; + state.toasts = next; + render(); + } + function setMsg(t, ty) { + var text = String(t || '').trim(); + if (!text) return; + var id = 'toast-' + (++state.toastSeq); + var level = ty === 'err' ? 'error' : (ty === 'warn' ? 'warning' : 'success'); + state.toasts.push({ id: id, text: text, level: level }); + if (state.toasts.length > 4) state.toasts = state.toasts.slice(state.toasts.length - 4); + render(); + setTimeout(function () { dismissToast(id); }, 4500); + } + function clearMsg() {} + function renderMsg() { return ''; } + function renderToasts() { + if (!state.toasts || state.toasts.length === 0) return ''; + var items = ''; + for (var i = 0; i < state.toasts.length; i++) { + var x = state.toasts[i]; + items += '
  1. ' + + '
    ' + esc(x.text) + '
    ' + + '' + + '
    ' + + '
  2. '; + } + return '
      ' + items + '
    '; + } + function askConfirm(opts) { + return new Promise(function (resolve) { + state.dialog = { + type: 'confirm', + title: String(opts && opts.title || 'Confirm'), + message: String(opts && opts.message || ''), + okText: String(opts && opts.okText || 'Yes'), + cancelText: String(opts && opts.cancelText || 'No'), + danger: !!(opts && opts.danger), + resolve: resolve + }; + render(); + }); + } + function askMoveFolder() { + return new Promise(function (resolve) { + state.dialog = { + type: 'move', + title: 'Move selected items', + message: 'Choose destination folder.', + selectedFolderId: '__none__', + resolve: resolve + }; + render(); + }); + } + function closeDialog(result) { + var d = state.dialog; + state.dialog = null; + render(); + if (d && typeof d.resolve === 'function') d.resolve(result); + } + function renderDialog() { + var d = state.dialog; + if (!d) return ''; + if (d.type === 'move') { + var options = ''; + for (var i = 0; i < state.folders.length; i++) { + var f = state.folders[i]; + var id = String(f.id || ''); + options += ''; + } + return '

    ' + esc(d.title) + '

    ' + esc(d.message) + '
    '; + } + return '

    ' + esc(d.title) + '

    ' + esc(d.message) + '
    '; + } function saveSession() { if (!state.session) { localStorage.removeItem(sessionKey()); return; } var persisted = { @@ -197,6 +279,7 @@ export function startNodewardenApp(runtimeConfig) { state.loginTotpError = ''; state.unlockPassword = ''; state.unlockError = ''; + state.unlockShowPassword = false; state.phase = 'locked'; if (broadcast !== false && state.lockChannel) { try { state.lockChannel.postMessage({ type: 'lock', at: Date.now() }); } catch (_) {} @@ -227,6 +310,7 @@ export function startNodewardenApp(runtimeConfig) { state.session.symMacKey = bytesToBase64(symKeyBytes.slice(32, 64)); state.unlockPassword = ''; state.unlockError = ''; + state.unlockShowPassword = false; await loadVault(); await loadAdminData(); state.phase = 'app'; @@ -241,7 +325,7 @@ export function startNodewardenApp(runtimeConfig) { } function logout(){ - state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.phase='login'; saveSession(); clearMsg(); render(); + state.session=null; state.profile=null; state.ciphers=[]; state.folders=[]; state.users=[]; state.invites=[]; state.folderFilterId=''; state.selectedCipherId=''; state.selectedMap={}; state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; state.unlockPassword=''; state.unlockError=''; state.unlockShowPassword=false; state.phase='login'; saveSession(); clearMsg(); render(); } async function authFetch(path, options){ @@ -600,7 +684,8 @@ export function startNodewardenApp(runtimeConfig) { } async function deleteSelectedCipher(){ var c=selectedCipher(); if(!c) return; - if(!window.confirm('Delete this item? This operation cannot be undone.')) return; + var ok = await askConfirm({ title: 'Delete item', message: 'Are you sure you want to delete this item?', okText: 'Yes', cancelText: 'No', danger: true }); + if(!ok) return; var r=await authFetch('/api/ciphers/'+encodeURIComponent(c.id),{method:'DELETE'}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Delete failed.', 'err'); closeDetailEdit(); @@ -838,20 +923,20 @@ export function startNodewardenApp(runtimeConfig) { return '' + '
    ' + '
    '+t('langSwitch')+'
    ' - + '
    ' - + '
    ' - + ' ' - + '
    '+t('brand')+'
    ' - + '
    '+t('subtitle')+'
    ' + + '
    ' + + '
    ' + + '
    '+t('login')+'
    ' + + '
    '+t('brand')+'
    ' + '
    ' + renderMsg() + '
    ' + '
    ' - + '
    ' - + ' ' + + '
    ' + + ' ' + '
    ' - + ' '; - } - detail='' - + '
    '+t('folder')+':
    ' - + renderDraftTypeCards(de) - + (Number(de.type||1)===1?('
    '+t('autofillOptions')+'
    '+ewsHtml+'' - + '
    '):'') - + '
    Additional options
    ' - + '' - + '
    ' - + '
    ' - + '
    Fields
    '+efsHtml+'
    ' - + '
    '; - } else detail=renderReadOnlyTypeDetails(c0, folderLabel, created, updated) - + '
    '; - } - - return '' - + renderMsg() - + '
    '+renderCreateMenu()+'
    '+rows+'
    '+detail+renderFieldModal()+'
    '; - } - - function renderSettingsTab(){ - var p=state.profile||{}; - var secret=currentTotpSecret(); - var lockMins = Number(state.lockTimeoutMinutes)||0; - return '' - + renderMsg() - + '

    '+t('settings')+'

    ' - + '

    Vault Lock

    ' - + '

    '+t('profile')+'

    ' - + '

    '+t('changePwd')+'

    After success, current sessions are revoked and you must log in again.
    ' - + '

    '+t('totpSetup')+'

    QR loading...
    Use secret key below
    Disable action prompts for master password.
    '; - } - function renderTotpDisableModal(){ - if(!state.totpDisableOpen) return ''; - return '' - + '

    '+t('disableTotp')+'

    '+t('totpDisableSub')+'
    ' - + (state.totpDisableError?'
    '+esc(state.totpDisableError)+'
    ':'') - + '
    ' - + '
    '; - } - - function renderHelpTab(){ - return '' - + '

    '+t('help')+'

    ' - + '

    '+t('helpSync')+'

    • '+t('helpSync1')+'
    • '+t('helpSync2')+'
    • '+t('helpSync3')+'
    ' - + '

    '+t('helpErr')+'

    • '+t('helpErr1')+'
    • '+t('helpErr2')+'
    • '+t('helpErr3')+'
    • '+t('helpErr4')+'
    ' - + '

    '+t('helpTb')+'

    • '+t('helpTb1')+'
    • '+t('helpTb2')+'
    • '+t('helpTb3')+'
    • '+t('helpTb4')+'
    '; - } - - function renderAdminTab(){ - var usersRows=''; - for(var i=0;i'+esc(u.name||'')+''+esc(u.role)+''+esc(u.status)+'' - + (canAct?'':'') - + (canAct?' ':'') - + ''; - } - if(!usersRows) usersRows='No users found.'; - - var inviteRows=''; - for(var j=0;j'+esc(inv.status)+''+esc(inv.expiresAt)+'' - + '' - + (inv.status==='active'?' ':'') - + ''; - } - if(!inviteRows) inviteRows='No invites found.'; - - return '' - + renderMsg() - + '
    ' - + '

    '+t('admin')+'

    ' - + '' - + '
    ' - + '

    '+t('createInvite')+'

    ' - + '

    '+t('users')+'

    '+usersRows+'
    '+t('email')+''+t('name')+''+t('role')+''+t('status')+''+t('action')+'
    ' - + '

    '+t('invites')+'

    '+inviteRows+'
    Code'+t('status')+'Expires At'+t('action')+'
    '; - } - - function renderApp(){ - var isAdmin=state.profile&&state.profile.role==='admin'; - var showFolders=state.tab==='vault'; - var folders='' - + '' - + ''; - for(var i=0;i📁'+esc(folderName)+''; } - var typeTree='' - + '' - + '' - + '' - + '' - + '' - + ''; - var content = state.tab==='vault'?renderVaultTab():state.tab==='settings'?renderSettingsTab():(state.tab==='admin'&&isAdmin)?renderAdminTab():renderHelpTab(); - - return '' - + '' - + '
    ' - + (showFolders?(' '):'') - + '
    '+content+'
    ' - + '
    '+renderTotpDisableModal(); - } - - function render(){ - var active = document.activeElement; - var keepSearchFocus = false; - var keepSearchSelStart = 0; - var keepSearchSelEnd = 0; - if (active instanceof HTMLInputElement && active.getAttribute('data-action') === 'vault-search') { - keepSearchFocus = true; - keepSearchSelStart = active.selectionStart == null ? 0 : active.selectionStart; - keepSearchSelEnd = active.selectionEnd == null ? keepSearchSelStart : active.selectionEnd; - } - if(state.phase==='loading'){ app.innerHTML='
    '+t('loading')+'
    ' + renderToasts() + renderDialog(); return; } - if(state.phase==='register'){ app.innerHTML=renderRegisterScreen() + renderToasts() + renderDialog(); return; } - if(state.phase==='login'){ app.innerHTML=renderLoginScreen() + renderToasts() + renderDialog(); return; } - if(state.phase==='locked'){ app.innerHTML=renderLockedScreen() + renderToasts() + renderDialog(); return; } - var prevContent = app.querySelector('.content'); - var prevSidebar = app.querySelector('.sidebar'); - var prevVaultList = app.querySelector('.vault-list'); - var contentTop = prevContent ? prevContent.scrollTop : 0; - var sidebarTop = prevSidebar ? prevSidebar.scrollTop : 0; - var vaultListTop = prevVaultList ? prevVaultList.scrollTop : 0; - app.innerHTML=renderApp() + renderToasts() + renderDialog(); - var nextContent = app.querySelector('.content'); - var nextSidebar = app.querySelector('.sidebar'); - var nextVaultList = app.querySelector('.vault-list'); - if(nextContent) nextContent.scrollTop = contentTop; - if(nextSidebar) nextSidebar.scrollTop = sidebarTop; - if(nextVaultList) nextVaultList.scrollTop = vaultListTop; - if (keepSearchFocus) { - var nextSearch = app.querySelector('input[data-action="vault-search"]'); - if (nextSearch instanceof HTMLInputElement) { - nextSearch.focus(); - try { nextSearch.setSelectionRange(keepSearchSelStart, keepSearchSelEnd); } catch (_) {} - } - } - updateLiveTotpDisplay(); - renderTotpSetupQr(); - } - - async function init(){ - var url=new URL(window.location.href); state.inviteCode=(url.searchParams.get('invite')||'').trim(); state.session=loadSession(); - loadLockSettings(); - ensureTotpTicker(); - ensureLockChannel(); - ensureAutoLockTicker(); - var st=await fetch('/setup/status'); var setup=await jsonOrNull(st); var registered=!!(setup&&setup.registered); - if(state.session){ - try{ await loadProfile(); state.phase='locked'; state.tab='vault'; render(); return; } catch(e){ state.session=null; saveSession(); } - } - state.phase=registered?'login':'register'; render(); - } - - async function onRegister(form){ - clearMsg(); - var fd=new FormData(form); var name=String(fd.get('name')||'').trim(); var email=String(fd.get('email')||'').trim().toLowerCase(); var p=String(fd.get('password')||''); var p2=String(fd.get('password2')||''); var invite=String(fd.get('inviteCode')||'').trim(); - state.registerName=name; state.registerEmail=email; state.registerPassword=p; state.registerPassword2=p2; state.inviteCode=invite; - if(!email||!p) return setMsg('Please input email and password.', 'err'); - if(p.length<12) return setMsg('Master password must be at least 12 chars.', 'err'); - if(p!==p2) return setMsg('Passwords do not match.', 'err'); - try{ - var it=defaultKdfIterations; var mk=await pbkdf2(p,email,it,32); var hash=await pbkdf2(mk,p,1,32); var ek=await hkdfExpand(mk,'enc',32); var em=await hkdfExpand(mk,'mac',32); var sym=crypto.getRandomValues(new Uint8Array(64)); var encKey=await encryptBw(sym,ek,em); - var kp=await crypto.subtle.generateKey({name:'RSA-OAEP', modulusLength:2048, publicExponent:new Uint8Array([1,0,1]), hash:'SHA-1'}, true, ['encrypt','decrypt']); - var pub=new Uint8Array(await crypto.subtle.exportKey('spki',kp.publicKey)); var prv=new Uint8Array(await crypto.subtle.exportKey('pkcs8',kp.privateKey)); var encPrv=await encryptBw(prv,sym.slice(0,32),sym.slice(32,64)); - var resp=await fetch('/api/accounts/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,name:name,masterPasswordHash:bytesToBase64(hash),key:encKey,kdf:0,kdfIterations:it,inviteCode:invite||undefined,keys:{publicKey:bytesToBase64(pub),encryptedPrivateKey:encPrv}})}); - var j=await jsonOrNull(resp); if(!resp.ok) return setMsg((j&&(j.error||j.error_description))||'Register failed.', 'err'); - state.registerName=''; state.registerEmail=''; state.registerPassword=''; state.registerPassword2=''; state.inviteCode=''; - state.phase='login'; state.loginEmail=email; state.loginPassword=''; setMsg('Registration succeeded. Please sign in.', 'ok'); - }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } - } - - async function onLoginPassword(form){ - clearMsg(); - var fd=new FormData(form); state.loginEmail=String(fd.get('email')||'').trim().toLowerCase(); state.loginPassword=String(fd.get('password')||''); - if(!state.loginEmail||!state.loginPassword) return setMsg('Please input email and password.', 'err'); - try{ - var d=await deriveLoginHash(state.loginEmail,state.loginPassword); - var body=new URLSearchParams(); body.set('grant_type','password'); body.set('username',state.loginEmail); body.set('password',d.hash); body.set('scope','api offline_access'); - var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body.toString()}); - var j=await jsonOrNull(resp); - if(!resp.ok){ - if(j&&j.TwoFactorProviders){ state.pendingLogin={email:state.loginEmail,passwordHash:d.hash,masterKey:d.masterKey}; state.loginTotpToken=''; state.loginTotpError=''; clearMsg(); render(); return; } - return setMsg((j&&(j.error_description||j.error))||'Login failed.', 'err'); - } - await onLoginSuccess(j,d.masterKey,state.loginEmail,state.loginPassword); - }catch(e){ setMsg(e&&e.message?e.message:String(e), 'err'); } - } - - async function onLoginTotp(form){ - if(!state.pendingLogin) return setMsg('TOTP flow is not ready.', 'err'); - var fd=new FormData(form); state.loginTotpToken=String(fd.get('totpToken')||'').trim(); if(!state.loginTotpToken){ state.loginTotpError='Please input TOTP code.'; render(); return; } - var b=new URLSearchParams(); b.set('grant_type','password'); b.set('username',state.pendingLogin.email); b.set('password',state.pendingLogin.passwordHash); b.set('scope','api offline_access'); b.set('twoFactorProvider','0'); b.set('twoFactorToken',state.loginTotpToken); - var resp=await fetch('/identity/connect/token',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:b.toString()}); - var j=await jsonOrNull(resp); if(!resp.ok){ state.loginTotpError=(j&&(j.error_description||j.error))||'TOTP verification failed.'; render(); return; } - state.loginTotpError=''; - await onLoginSuccess(j,state.pendingLogin.masterKey,state.pendingLogin.email,state.loginPassword); - } - - async function onLoginSuccess(tokenJson, masterKey, email, password){ - state.session={accessToken:tokenJson.access_token,refreshToken:tokenJson.refresh_token,email:email}; saveSession(); state.pendingLogin=null; state.loginTotpToken=''; state.loginTotpError=''; - await loadProfile(); - try{ - var ek=await hkdfExpand(masterKey,'enc',32); var em=await hkdfExpand(masterKey,'mac',32); - var symKeyBytes=await decryptBw(state.profile.key,ek,em); - if(symKeyBytes){ state.session.symEncKey=bytesToBase64(symKeyBytes.slice(0,32)); state.session.symMacKey=bytesToBase64(symKeyBytes.slice(32,64)); } - }catch(e){ console.warn('Key derivation failed:',e); } - await loadVault(); await loadAdminData(); state.phase='app'; state.tab='vault'; state.lockLastActiveTs=Date.now(); - setMsg('Login success.', 'ok'); - } - async function onSaveLockSettings(form){ - var fd=new FormData(form); - var mins=Number(fd.get('lockTimeoutMinutes')||0); - if(!Number.isFinite(mins)||mins<0) mins=15; - state.lockTimeoutMinutes=mins; - saveLockSettings(); - state.lockLastActiveTs=Date.now(); - setMsg('Lock settings saved.', 'ok'); - } - async function onSaveProfile(form){ var fd=new FormData(form); var n=String(fd.get('name')||'').trim(); var em=String(fd.get('email')||'').trim().toLowerCase(); var r=await authFetch('/api/accounts/profile',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n,email:em})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Save profile failed.', 'err'); state.profile=j; render(); setMsg('Profile updated.', 'ok'); } - async function onChangePassword(form){ - var fd=new FormData(form); - var currentPassword=String(fd.get('currentPassword')||''); - var newPassword=String(fd.get('newPassword')||''); - var newPassword2=String(fd.get('newPassword2')||''); - if(!currentPassword||!newPassword) return setMsg('Current/new password is required.', 'err'); - if(newPassword.length<12) return setMsg('New master password must be at least 12 chars.', 'err'); - if(newPassword!==newPassword2) return setMsg('New passwords do not match.', 'err'); - if(newPassword===currentPassword) return setMsg('New password must be different.', 'err'); - var email=String(state.profile&&state.profile.email?state.profile.email:'').toLowerCase(); - if(!email) return setMsg('Profile email missing.', 'err'); - try{ - var current=await deriveLoginHash(email,currentPassword); - var userSym=buildSymmetricKeyBytes(); - if(!userSym){ - var oldEk=await hkdfExpand(current.masterKey,'enc',32); - var oldEm=await hkdfExpand(current.masterKey,'mac',32); - userSym=await decryptBw(state.profile.key,oldEk,oldEm); - } - if(!userSym||userSym.length<64) return setMsg('Unable to load vault key for password rotation.', 'err'); - var nextMasterKey=await pbkdf2(newPassword,email,current.kdfIterations,32); - var nextHash=await pbkdf2(nextMasterKey,newPassword,1,32); - var nextEk=await hkdfExpand(nextMasterKey,'enc',32); - var nextEm=await hkdfExpand(nextMasterKey,'mac',32); - var newKey=await encryptBw(userSym.slice(0,64),nextEk,nextEm); - var r=await authFetch('/api/accounts/password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({currentPasswordHash:current.hash,newMasterPasswordHash:bytesToBase64(nextHash),newKey:newKey,kdf:0,kdfIterations:current.kdfIterations})}); - var j=await jsonOrNull(r); - if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Change master password failed.', 'err'); - logout(); - setMsg('Master password changed. Please log in again.', 'ok'); - }catch(e){ - setMsg('Change master password failed: '+(e&&e.message?e.message:String(e)), 'err'); - } - } - async function onEnableTotp(form){ var fd=new FormData(form); state.totpSetupSecret=String(fd.get('secret')||'').toUpperCase().replace(/[\s-]/g,'').replace(/=+$/g,''); state.totpSetupToken=String(fd.get('token')||'').trim(); if(!state.totpSetupSecret) return setMsg('TOTP secret is required.', 'err'); if(!state.totpSetupToken) return setMsg('TOTP token is required.', 'err'); var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:true,secret:state.totpSetupSecret,token:state.totpSetupToken})}); var j=await jsonOrNull(r); if(!r.ok) return setMsg((j&&(j.error||j.error_description))||'Enable TOTP failed.', 'err'); state.totpSetupToken=''; render(); setMsg('TOTP enabled.', 'ok'); } - function onDisableTotp(){ state.totpDisableOpen=true; state.totpDisablePassword=''; state.totpDisableError=''; render(); } - async function onDisableTotpSubmit(form){ - var fd=new FormData(form); state.totpDisablePassword=String(fd.get('masterPassword')||''); - if(!state.totpDisablePassword){ state.totpDisableError='Please input master password.'; render(); return; } - try{ - var d=await deriveLoginHash(state.profile.email,state.totpDisablePassword); - var r=await authFetch('/api/accounts/totp',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:false,masterPasswordHash:d.hash})}); - var j=await jsonOrNull(r); - if(!r.ok){ state.totpDisableError=(j&&(j.error||j.error_description))||'Disable TOTP failed.'; render(); return; } - state.totpDisableOpen=false; state.totpDisablePassword=''; state.totpDisableError=''; - render(); setMsg('TOTP disabled.', 'ok'); - }catch(e){ - state.totpDisableError='Disable TOTP failed: '+(e&&e.message?e.message:String(e)); - render(); - } - } - - async function onBulkDelete(){ var ids=[]; for(var k in state.selectedMap){ if(state.selectedMap[k]) ids.push(k);} if(ids.length===0) return setMsg('Select items first.', 'err'); var ok=await askConfirm({title:'Delete items',message:'Are you sure you want to delete '+ids.length+' selected items?',okText:'Yes',cancelText:'No',danger:true}); if(!ok) return; for(var i=0;i=0&&wi=0&&fi=0&&wi { - const s = document.createElement('script'); - s.src = '/web/vendor/qrcode-generator.min.js'; - s.async = true; - s.onload = () => resolve(null); - s.onerror = () => resolve(null); - document.head.appendChild(s); - }); -} - -async function loadRuntimeConfig() { - try { - const resp = await fetch('/api/web/config', { method: 'GET' }); - if (!resp.ok) throw new Error('runtime config request failed'); - return await resp.json(); - } catch { - return { defaultKdfIterations: 600000 }; - } -} - -await ensureQrLibrary(); -const cfg = await loadRuntimeConfig(); -startNodewardenApp(cfg || { defaultKdfIterations: 600000 }); diff --git a/public/web/styles.css b/public/web/styles.css deleted file mode 100644 index 324f78b..0000000 --- a/public/web/styles.css +++ /dev/null @@ -1,812 +0,0 @@ - -:root { - --bg: #F3F5F8; - --panel: #FFFFFF; - --line: #DEE2E6; - --text-primary: #212529; - --text-secondary: #6C757D; - --primary: #175DDC; - --primary-hover: #144eb8; - --danger: #DC3545; - --danger-hover: #C82333; - --danger-bg: #F8D7DA; - --success: #198754; - --success-bg: #D1E7DD; - --border-color: #DEE2E6; - --radius: 6px; - --radius-sm: 4px; - --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); - --shadow: 0 4px 12px rgba(0,0,0,0.08); - --shadow-lg: 0 8px 24px rgba(0,0,0,0.12); - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - } - * { box-sizing: border-box; } - html, body { height: 100%; margin: 0; } - body { - color: var(--text-primary); - font-family: var(--font-sans); - background-color: var(--bg); - -webkit-font-smoothing: antialiased; - } - #app { height: 100%; display: flex; flex-direction: column; } - - /* Auth Pages */ - .auth-page { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; - padding: 20px; - position: relative; - } - .lang-switch { - position: absolute; - top: 24px; - right: 24px; - cursor: pointer; - color: var(--text-secondary); - font-size: 14px; - font-weight: 500; - } - .lang-switch:hover { color: var(--primary); } - .auth-card { - width: 100%; - max-width: 420px; - background: var(--panel); - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 40px; - box-shadow: var(--shadow); - } - .auth-header { - text-align: center; - margin-bottom: 32px; - } - .auth-logo { - width: 48px; - height: 48px; - background: var(--primary); - border-radius: 12px; - margin: 0 auto 16px; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-weight: bold; - font-size: 24px; - } - .auth-logo::after { content: "NW"; } - .auth-title { - font-size: 24px; - font-weight: 700; - color: var(--text-primary); - margin-bottom: 8px; - } - .auth-subtitle { - color: var(--text-secondary); - font-size: 15px; - } - .auth-footer { - margin-top: 24px; - text-align: center; - font-size: 14px; - } - .auth-footer a { - color: var(--primary); - text-decoration: none; - font-weight: 500; - } - .auth-footer a:hover { text-decoration: underline; } - - /* Forms */ - .form-group { margin-bottom: 20px; } - .form-label { - display: block; - margin-bottom: 8px; - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - } - .form-input { - width: 100%; - height: 42px; - padding: 8px 12px; - font-size: 15px; - border: 1px solid var(--border-color); - border-radius: var(--radius-sm); - background: #fff; - color: var(--text-primary); - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; - } - .form-input:focus { - outline: none; - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(23, 93, 220, 0.15); - } - - /* Buttons */ - .btn { - display: inline-flex; - align-items: center; - justify-content: center; - height: 42px; - padding: 0 20px; - font-size: 15px; - font-weight: 600; - border-radius: var(--radius-sm); - border: 1px solid transparent; - cursor: pointer; - transition: all 0.15s ease-in-out; - } - .btn-primary { - background: var(--primary); - color: #fff; - } - .btn-primary:hover { background: var(--primary-hover); } - .btn-secondary { - background: #fff; - border-color: var(--border-color); - color: var(--text-primary); - } - .btn-secondary:hover { background: #F8F9FA; } - .btn-danger { - background: var(--danger); - color: #fff; - } - .btn-danger:hover { background: var(--danger-hover); } - - /* Alerts */ - .alert { - padding: 12px 16px; - border-radius: var(--radius-sm); - font-size: 14px; - margin-bottom: 24px; - border: 1px solid transparent; - } - .alert-success { background: var(--success-bg); color: var(--success); border-color: #BADBCC; } - .alert-danger { background: var(--danger-bg); color: var(--danger); border-color: #F5C2C7; } - .toast-stack { - position: fixed; - top: 16px; - right: 16px; - z-index: 1200; - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 10px; - width: min(420px, calc(100vw - 24px)); - } - .toast-item { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - border-radius: 10px; - box-shadow: var(--shadow); - border: 1px solid #c9e9d6; - background: #dff4e5; - color: #0f5132; - padding: 14px 14px; - overflow: hidden; - } - .toast-item.error { - border-color: #f5c2c7; - background: #f8d7da; - color: #842029; - } - .toast-item.warning { - border-color: #ffe69c; - background: #fff3cd; - color: #664d03; - } - .toast-text { - font-size: 15px; - font-weight: 600; - padding-right: 10px; - } - .toast-close { - border: none; - background: transparent; - color: inherit; - font-size: 22px; - cursor: pointer; - line-height: 1; - opacity: 0.8; - } - .toast-close:hover { opacity: 1; } - .toast-bar { - position: absolute; - left: 0; - bottom: 0; - height: 3px; - width: 100%; - background: rgba(0,0,0,0.12); - transform-origin: left center; - animation: toastBar 4.5s linear forwards; - } - @keyframes toastBar { from { transform: scaleX(1); } to { transform: scaleX(0); } } - .dialog-mask { - position: fixed; - inset: 0; - background: rgba(17, 24, 39, 0.45); - z-index: 1300; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - } - .dialog-card { - width: min(540px, 100%); - background: #fff; - border: 1px solid var(--border-color); - border-radius: 20px; - box-shadow: var(--shadow-lg); - padding: 24px 24px; - text-align: center; - } - .dialog-icon { - font-size: 34px; - line-height: 1; - color: #f4b400; - margin-bottom: 12px; - } - .dialog-title { - margin: 0 0 8px 0; - font-size: 34px; - line-height: 1.15; - color: #0f172a; - font-weight: 700; - } - .dialog-msg { - margin: 0 auto 18px auto; - color: #334155; - font-size: 20px; - max-width: 90%; - } - .dialog-btn { - width: 100%; - height: 56px; - border-radius: 999px; - font-size: 28px; - margin-bottom: 10px; - } - .form-dialog { - text-align: left; - } - .form-dialog .dialog-title { - font-size: 30px; - margin-bottom: 8px; - text-align: center; - } - .form-dialog .dialog-msg { - font-size: 16px; - max-width: 100%; - margin-bottom: 14px; - text-align: center; - } - .form-dialog .dialog-btn { - font-size: 22px; - } - .dialog-error { - background: #f8d7da; - border: 1px solid #f5c2c7; - color: #842029; - border-radius: 10px; - padding: 10px 12px; - font-size: 14px; - margin: 0 0 12px 0; - } - .unlock-card { - max-width: 620px; - padding: 30px 34px; - } - .unlock-pwd-wrap { - position: relative; - margin-bottom: 14px; - } - .unlock-pwd-input { - padding-right: 88px; - height: 48px; - border-radius: 10px; - border-color: #3f5b9e; - } - .auth-page .form-input { - height: 48px; - border-radius: 10px; - border-color: #3f5b9e; - padding: 10px 12px; - } - .auth-page .form-input:focus { - border-color: #3f5b9e; - box-shadow: none; - } - .unlock-eye-btn { - position: absolute; - right: 42px; - bottom: 8px; - width: 30px; - height: 30px; - border: none; - background: transparent; - color: #233a72; - font-size: 17px; - cursor: pointer; - } - .unlock-main-btn { - width: 100%; - margin-top: 8px; - height: 44px; - border-radius: 999px; - } - .unlock-secondary-btn { - width: 100%; - height: 44px; - border-radius: 999px; - border-color: var(--primary); - color: var(--primary); - background: #fff; - } - .unlock-or { - text-align: center; - color: #1f2f4f; - font-size: 16px; - margin: 10px 0; - line-height: 1; - } - .totp-qr-card { - background:#fff; - padding:16px; - border:1px solid var(--border-color); - border-radius:8px; - width: 200px; - min-height: 200px; - display:flex; - align-items:center; - justify-content:center; - } - .totp-qr-fallback { - width:100%; - min-height:168px; - border:1px dashed var(--border-color); - border-radius:8px; - display:flex; - flex-direction:column; - align-items:center; - justify-content:center; - color:var(--text-secondary); - text-align:center; - padding:8px; - } - - /* App Layout */ - .navbar { - height: 64px; - background: var(--primary); - color: #fff; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - flex-shrink: 0; - } - .nav-brand { - display: flex; - align-items: center; - gap: 12px; - font-size: 20px; - font-weight: 700; - } - .nav-logo { - width: 32px; - height: 32px; - background: #fff; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - color: var(--primary); - font-weight: bold; - font-size: 16px; - } - .nav-logo::after { content: "NW"; } - .nav-links { - display: flex; - gap: 8px; - } - .nav-link { - color: rgba(255,255,255,0.8); - text-decoration: none; - padding: 8px 16px; - border-radius: var(--radius-sm); - font-weight: 500; - font-size: 15px; - transition: all 0.15s; - } - .nav-link:hover { color: #fff; background: rgba(255,255,255,0.1); } - .nav-link.active { color: #fff; background: rgba(255,255,255,0.2); } - .nav-user { - display: flex; - align-items: center; - } - .nav-user .lang-switch { - color: rgba(255,255,255,0.8); - } - .nav-user .lang-switch:hover { color: #fff; } - .nav-user .btn-secondary { - height: 32px; - padding: 0 12px; - font-size: 13px; - background: rgba(255,255,255,0.1); - border-color: transparent; - color: #fff; - } - .nav-user .btn-secondary:hover { background: rgba(255,255,255,0.2); } - - .app-body { - display: flex; - flex: 1; - overflow: hidden; - width: min(1520px, calc(100vw - 40px)); - margin: 14px auto 16px; - border: 1px solid var(--border-color); - border-radius: 12px; - background: #fff; - box-shadow: var(--shadow); - height: calc(100vh - 64px - 30px); - } - .sidebar { - width: 300px; - background: #fff; - border-right: 1px solid var(--border-color); - padding: 14px; - overflow-y: auto; - } - .sidebar-block { - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 10px; - background: #fff; - margin-bottom: 12px; - } - .sidebar-title { - font-size: 12px; - font-weight: 700; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 8px; - } - .search-input { - width: 100%; - height: 38px; - border: 1px solid #2f6fec; - border-radius: 9px; - padding: 0 12px; - font-size: 15px; - outline: none; - } - .tree-btn { - width: 100%; - text-align: left; - padding: 8px 10px; - background: transparent; - border: none; - color: var(--text-primary); - font-size: 14px; - font-weight: 500; - border-radius: var(--radius-sm); - cursor: pointer; - margin-bottom: 2px; - display: flex; - align-items: center; - gap: 8px; - } - .tree-btn:hover { background: var(--bg); } - .tree-btn.active { color: var(--primary); font-weight: 700; background: #eef4ff; } - - .content { - flex: 1; - padding: 16px 18px; - overflow-y: auto; - background: #F8FAFC; - } - .content .btn { - height: 36px; - padding: 0 16px; - border-radius: 15px; - } - .content .btn-primary { - background: var(--primary); - border-color: var(--primary); - color: #fff; - } - .content .btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } - .content .btn-secondary { - background: #fff; - border-color: var(--primary); - color: var(--primary); - } - .content .btn-secondary:hover { background: #edf3ff; } - .content .btn-danger { - background: #fff; - border-color: #e11d48; - color: #e11d48; - } - .content .btn-danger:hover { background: #fff1f2; } - .content .btn-danger-icon { - width: 42px; - padding: 0; - border: none; - background: transparent; - color: #e11d48; - font-size: 26px; - line-height: 1; - } - .content .btn-danger-icon:hover { - border: 1px solid #fecdd3; - background: #fff1f2; - } - - /* Vault Grid */ - .vault-grid { - display: grid; - grid-template-columns: minmax(380px, 44%) 1fr; - gap: 16px; - height: calc(100vh - 145px); - } - .vault-list-col { min-width: 0; display:flex; flex-direction:column; } - .vault-list-head { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 8px; - justify-content: flex-start; - align-items: center; - } - .vault-list { - background: #fff; - border: 1px solid var(--border-color); - border-radius: var(--radius); - overflow-y: auto; - flex: 1; - } - .vault-item { - padding: 13px 14px; - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - background: #fff; - } - .vault-item:hover { background: #f6f9ff; } - .vault-item.active { background: #ecf3ff; } - .vault-item:last-child { border-bottom: none; } - .vault-item-check { width:18px; height:18px; } - .vault-item-main { display:flex; align-items:center; gap:12px; min-width:0; } - .vault-item-icon { - width: 24px; - height: 24px; - border-radius: 5px; - object-fit: contain; - flex-shrink: 0; - background: #fff; - } - .vault-item-icon-wrap { width:24px; height:24px; position:relative; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; } - .vault-item-icon-fallback { - display:inline-flex; - align-items:center; - justify-content:center; - font-size: 24px; - color: #6b7a90; - border: none; - background: transparent; - } - .vault-item-text { min-width:0; } - .vault-item-title { - color: #1457d6; - font-size: 16px; - font-weight: 700; - line-height: 1.1; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - .vault-item-sub { - color: #64748b; - font-size: 13px; - margin-top: 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - .vault-detail { - overflow-y: auto; - padding-right: 2px; - } - .card { - background: #fff; - border: 1px solid var(--border-color); - border-radius: 14px; - padding: 16px 18px; - margin-bottom: 14px; - box-shadow: var(--shadow-sm); - } - .vault-detail-head .vault-detail-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; } - .vault-detail-folder { color: #334155; font-size: 14px; } - .card-title { - font-size: 15px; - font-weight: 700; - color: #334155; - margin-bottom: 10px; - } - .field-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - padding: 10px 0; - border-bottom: 1px solid #e8edf4; - } - .field-row:last-child { border-bottom: none; } - .field-label { color: #64748b; font-size: 13px; margin-bottom: 3px; } - .field-value { color: #0f172a; font-size: 17px; word-break: break-all; } - .field-sub { color: #64748b; font-size: 12px; margin-top: 2px; } - .icon-btn { - border: 1px solid var(--border-color); - background: #fff; - border-radius: 8px; - height: 30px; - padding: 0 10px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - margin-left: 6px; - } - .icon-btn:hover { background: #f8fafc; } - .link-btn { - border: none; - background: transparent; - color: #1457d6; - font-weight: 700; - font-size: 16px; - cursor: pointer; - padding: 2px 0; - } - .link-btn:hover { text-decoration: underline; } - .create-menu-wrap { position: relative; } - .create-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - min-width: 180px; - background: #fff; - border: 1px solid var(--border-color); - border-radius: 10px; - box-shadow: var(--shadow); - z-index: 30; - padding: 6px; - } - .create-menu-item { - width: 100%; - text-align: left; - border: none; - background: transparent; - padding: 8px 10px; - border-radius: 8px; - font-size: 15px; - cursor: pointer; - } - .create-menu-item:hover { background: #eff5ff; } - .field-modal { max-width: 560px; } - .field-modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom: 8px; } - .field-modal-head h3 { margin: 0; } - .history-line { color: #64748b; font-size: 13px; line-height: 1.8; } - .detail-input { - width: 100%; - min-height: 36px; - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 7px 10px; - font-size: 14px; - background: #fff; - color: #0f172a; - } - .detail-input:focus { outline: none; border-color: #2f6fec; box-shadow: 0 0 0 3px rgba(47,111,236,0.12); } - .detail-textarea { min-height: 100px; resize: vertical; } - .detail-actions { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 8px; - gap: 8px; - } - .detail-actions .btn { margin-right: 8px; } - .detail-actions .btn:last-child { margin-right: 0; } - .vault-empty { - min-height: 120px; - display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); - padding: 20px; - text-align: center; - background: #fff; - border: 1px solid var(--border-color); - border-radius: var(--radius); - } - - /* Common Components */ - .panel { - background: #fff; - border: 1px solid var(--border-color); - border-radius: var(--radius); - padding: 24px; - margin-bottom: 24px; - box-shadow: var(--shadow-sm); - } - .panel h3 { margin: 0 0 20px 0; font-size: 18px; font-weight: 600; border-bottom: 1px solid var(--border-color); padding-bottom: 16px; } - - .table { width: 100%; border-collapse: collapse; font-size: 14px; } - .table th, .table td { padding: 12px 16px; border-bottom: 1px solid var(--border-color); text-align: left; } - .table th { font-weight: 600; color: var(--text-secondary); background: var(--bg); } - - .badge { - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; - font-weight: 600; - background: var(--bg); - color: var(--text-secondary); - } - .badge.success { background: var(--success-bg); color: var(--success); } - .badge.danger { background: var(--danger-bg); color: var(--danger); } - - .kv { margin-bottom: 12px; font-size: 14px; line-height: 1.5; display: flex; } - .kv b { color: var(--text-secondary); font-weight: 600; width: 120px; flex-shrink: 0; } - - .totp-mask { - position: fixed; - inset: 0; - background: rgba(0,0,0,0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - } - .totp-box { - width: 100%; - max-width: 400px; - background: #fff; - border-radius: var(--radius); - padding: 32px; - box-shadow: var(--shadow-lg); - } - @media (max-width: 980px) { - .app-body { - width: calc(100vw - 16px); - margin: 8px auto; - border-radius: 10px; - } - .sidebar { width: 280px; } - } - @media (max-width: 760px) { - .app-body { - width: 100%; - margin: 0; - border: none; - border-radius: 0; - box-shadow: none; - height: calc(100vh - 64px); - } - .sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - } - .vault-grid { grid-template-columns: 1fr; } - } diff --git a/public/web/vault-utils.js b/public/web/vault-utils.js deleted file mode 100644 index 8aafd4d..0000000 --- a/public/web/vault-utils.js +++ /dev/null @@ -1,44 +0,0 @@ -export function parseFieldType(v) { - if (v === null || v === undefined) return 0; - if (typeof v === 'number' && isFinite(v)) return v === 1 || v === 2 || v === 3 ? v : 0; - var s = String(v).trim().toLowerCase(); - if (s === '1' || s === 'hidden') return 1; - if (s === '2' || s === 'boolean' || s === 'checkbox') return 2; - if (s === '3' || s === 'linked' || s === 'link') return 3; - return 0; -} - -export function selectedCount(selectedMap) { - var n = 0; - for (var k in selectedMap) if (selectedMap[k]) n++; - return n; -} - -export function cipherTypeKey(c) { - var tnum = Number(c && c.type || 1); - if (tnum === 1) return 'login'; - if (tnum === 3) return 'card'; - if (tnum === 4) return 'identity'; - if (tnum === 2) return 'note'; - return 'other'; -} - -export function hostFromUri(uri) { - if (!uri) return ''; - try { - var normalized = /^https?:\/\//i.test(uri) ? uri : ('https://' + uri); - return new URL(normalized).hostname || ''; - } catch (_) { - return ''; - } -} - -export function firstCipherUri(c) { - var uris = c && c.login && Array.isArray(c.login.uris) ? c.login.uris : []; - for (var i = 0; i < uris.length; i++) { - var u = uris[i] && (uris[i].decUri || uris[i].uri); - if (u) return u; - } - return ''; -} - diff --git a/public/web/vendor/qrcode-generator.min.js b/public/web/vendor/qrcode-generator.min.js deleted file mode 100644 index 1434c6f..0000000 --- a/public/web/vendor/qrcode-generator.min.js +++ /dev/null @@ -1 +0,0 @@ -var qrcode=function(){function i(t,r){function a(t,r){g=function(t){for(var r=new Array(t),e=0;e>e&1);g[Math.floor(e/3)][e%3+l-8-3]=n}for(e=0;e<18;e+=1){n=!t&&1==(r>>e&1);g[e%3+l-8-3][Math.floor(e/3)]=n}},v=function(t,r){for(var e=f<<3|r,n=B.getBCHTypeInfo(e),o=0;o<15;o+=1){var i=!t&&1==(n>>o&1);o<6?g[o][8]=i:o<8?g[o+1][8]=i:g[l-15+o][8]=i}for(o=0;o<15;o+=1){i=!t&&1==(n>>o&1);o<8?g[8][l-o-1]=i:o<9?g[8][15-o-1+1]=i:g[8][15-o-1]=i}g[l-8][8]=!t},d=function(t,r){for(var e=-1,n=l-1,o=7,i=0,a=B.getMaskFunction(r),u=l-1;0>>o&1)),a(n,u-f)&&(c=!c),g[n][u-f]=c,-1==(o-=1)&&(i+=1,o=7)}if((n+=e)<0||l<=n){n-=e,e=-e;break}}},w=function(t,r,e){for(var n=b.getRSBlocks(t,r),o=M(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+=""},s.createSvgTag=function(t,r,e,n){var o={};"object"==typeof t&&(t=(o=t).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,f,c=s.getModuleCount()*t+2*r,g="";for(f="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",g+=''+p(n.text)+"":"",g+=e.text?''+p(e.text)+"":"",g+='',g+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return s.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*s.getModuleCount()+2*t,u=t,f=a-t,c={"██":"█","█ ":"▀"," █":"▄"," ":" "},g={"██":"▀","█ ":"▀"," █":" "," ":" "},l="";for(r=0;r>>8),r.push(255&o)):r.push(a)}}return r}};var r,t,a=1,u=2,o=4,f=8,y={L:1,M:0,Q:3,H:2},e=0,n=1,c=2,g=3,l=4,h=5,s=6,v=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],(t={}).getBCHTypeInfo=function(t){for(var r=t<<10;0<=d(r)-d(1335);)r^=1335<>>=1;return r}var w=function(){for(var r=new Array(256),e=new Array(256),t=0;t<8;t+=1)r[t]=1<>>8)},writeBytes:function(t,r,e){r=r||0,e=e||t.length;for(var n=0;n>>7-t%8&1)},put:function(t,r){for(var e=0;e>>r-e-1&1))},getLengthInBits:function(){return n},putBit:function(t){var r=Math.floor(n/8);e.length<=r&&e.push(0),t&&(e[r]|=128>>>n%8),n+=1}};return o},x=function(t){var r=a,n=t,e={getMode:function(){return r},getLength:function(t){return n.length},write:function(t){for(var r=n,e=0;e+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e=e.length){if(0==i)return-1;throw"unexpected end of file./"+i}var t=e.charAt(n);if(n+=1,"="==t)return i=0,-1;t.match(/^\s$/)||(o=o<<6|a(t.charCodeAt(0)),i+=6)}var r=o>>>i-8&255;return i-=8,r}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return r},I=function(t,r,e){for(var n=function(t,r){var n=t,o=r,l=new Array(t*r),e={setPixel:function(t,r,e){l[r*n+t]=e},write:function(t){t.writeString("GIF87a"),t.writeShort(n),t.writeShort(o),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(n),t.writeShort(o),t.writeByte(0);var r=i(2);t.writeByte(2);for(var e=0;255>>r!=0)throw"length over";for(;8<=n+r;)e.writeByte(255&(t<>>=8-n,n=o=0;o|=t<>>o-6),o-=6},t.flush=function(){if(0>6,128|63&n):n<55296||57344<=n?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}(function(){return qrcode}); \ No newline at end of file diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..9945f47 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,12 @@ + + + + + + NodeWarden + + +
    + + + diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx new file mode 100644 index 0000000..502094e --- /dev/null +++ b/webapp/src/App.tsx @@ -0,0 +1,756 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import { Link, Route, Switch, useLocation } from 'wouter'; +import { useQuery } from '@tanstack/react-query'; +import AuthViews from '@/components/AuthViews'; +import ConfirmDialog from '@/components/ConfirmDialog'; +import ToastHost from '@/components/ToastHost'; +import VaultPage from '@/components/VaultPage'; +import SettingsPage from '@/components/SettingsPage'; +import AdminPage from '@/components/AdminPage'; +import HelpPage from '@/components/HelpPage'; +import { + changeMasterPassword, + createCipher, + createAuthedFetch, + createInvite, + deleteCipher, + deleteUser, + deriveLoginHash, + bulkMoveCiphers, + getCiphers, + getFolders, + getProfile, + getSetupStatus, + getWebConfig, + listAdminInvites, + listAdminUsers, + loadSession, + loginWithPassword, + registerAccount, + revokeInvite, + saveSession, + setTotp, + setUserStatus, + updateCipher, + unlockVaultKey, + updateProfile, +} from '@/lib/api'; +import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; +import type { AppPhase, Cipher, Folder, Profile, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; + +interface PendingTotp { + email: string; + passwordHash: string; + masterKey: Uint8Array; +} + +export default function App() { + const [location, navigate] = useLocation(); + const [phase, setPhase] = useState('loading'); + const [session, setSessionState] = useState(null); + const [profile, setProfile] = useState(null); + const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000); + const [setupRegistered, setSetupRegistered] = useState(true); + + const [loginValues, setLoginValues] = useState({ email: '', password: '' }); + const [registerValues, setRegisterValues] = useState({ + name: '', + email: '', + password: '', + password2: '', + inviteCode: '', + }); + const [unlockPassword, setUnlockPassword] = useState(''); + const [pendingTotp, setPendingTotp] = useState(null); + const [totpCode, setTotpCode] = useState(''); + + const [disableTotpOpen, setDisableTotpOpen] = useState(false); + const [disableTotpPassword, setDisableTotpPassword] = useState(''); + + const [confirm, setConfirm] = useState<{ + title: string; + message: string; + danger?: boolean; + onConfirm: () => void; + } | null>(null); + + const [toasts, setToasts] = useState([]); + const [decryptedFolders, setDecryptedFolders] = useState([]); + const [decryptedCiphers, setDecryptedCiphers] = useState([]); + + function setSession(next: SessionState | null) { + setSessionState(next); + saveSession(next); + } + + function pushToast(type: ToastMessage['type'], text: string) { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + setToasts((prev) => [...prev.slice(-3), { id, type, text }]); + window.setTimeout(() => { + setToasts((prev) => prev.filter((x) => x.id !== id)); + }, 4500); + } + + const authedFetch = useMemo( + () => + createAuthedFetch( + () => session, + (next) => { + setSession(next); + if (!next) { + setProfile(null); + setPhase(setupRegistered ? 'login' : 'register'); + } + } + ), + [session, setupRegistered] + ); + + useEffect(() => { + let mounted = true; + (async () => { + const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]); + if (!mounted) return; + setSetupRegistered(setup.registered); + setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000)); + + const loaded = loadSession(); + if (!loaded) { + setPhase(setup.registered ? 'login' : 'register'); + return; + } + setSession(loaded); + + try { + const profileResp = await getProfile( + createAuthedFetch( + () => loaded, + (next) => { + if (!next) return; + setSession(next); + } + ) + ); + if (!mounted) return; + setProfile(profileResp); + setPhase('locked'); + } catch { + setSession(null); + setPhase(setup.registered ? 'login' : 'register'); + } + })(); + + return () => { + mounted = false; + }; + }, []); + + async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) { + const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email }; + const tempFetch = createAuthedFetch( + () => baseSession, + () => {} + ); + const profileResp = await getProfile(tempFetch); + const keys = await unlockVaultKey(profileResp.key, masterKey); + const nextSession = { ...baseSession, ...keys }; + setSession(nextSession); + setProfile(profileResp); + setPendingTotp(null); + setTotpCode(''); + setPhase('app'); + if (location === '/' || location === '/login' || location === '/register' || location === '/lock') { + navigate('/vault'); + } + pushToast('success', 'Login success'); + } + + async function handleLogin() { + if (!loginValues.email || !loginValues.password) { + pushToast('error', 'Please input email and password'); + return; + } + try { + const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations); + const token = await loginWithPassword(loginValues.email, derived.hash); + if ('access_token' in token && token.access_token) { + await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey); + return; + } + const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; + if (tokenError.TwoFactorProviders) { + setPendingTotp({ + email: loginValues.email.toLowerCase(), + passwordHash: derived.hash, + masterKey: derived.masterKey, + }); + setTotpCode(''); + return; + } + pushToast('error', tokenError.error_description || tokenError.error || 'Login failed'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Login failed'); + } + } + + async function handleTotpVerify() { + if (!pendingTotp) return; + if (!totpCode.trim()) { + pushToast('error', 'Please input TOTP code'); + return; + } + const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim()); + if ('access_token' in token && token.access_token) { + await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey); + return; + } + const tokenError = token as { error_description?: string; error?: string }; + pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed'); + } + + async function handleRegister() { + if (!registerValues.email || !registerValues.password) { + pushToast('error', 'Please input email and password'); + return; + } + if (registerValues.password.length < 12) { + pushToast('error', 'Master password must be at least 12 chars'); + return; + } + if (registerValues.password !== registerValues.password2) { + pushToast('error', 'Passwords do not match'); + return; + } + const resp = await registerAccount({ + email: registerValues.email.toLowerCase(), + name: registerValues.name.trim(), + password: registerValues.password, + inviteCode: registerValues.inviteCode.trim(), + fallbackIterations: defaultKdfIterations, + }); + if (!resp.ok) { + pushToast('error', resp.message); + return; + } + setLoginValues({ email: registerValues.email.toLowerCase(), password: '' }); + setPhase('login'); + pushToast('success', 'Registration succeeded. Please sign in.'); + } + + async function handleUnlock() { + if (!session || !profile) return; + if (!unlockPassword) { + pushToast('error', 'Please input master password'); + return; + } + try { + const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations); + const keys = await unlockVaultKey(profile.key, derived.masterKey); + setSession({ ...session, ...keys }); + setUnlockPassword(''); + setPhase('app'); + if (location === '/' || location === '/lock') navigate('/vault'); + pushToast('success', 'Unlocked'); + } catch { + pushToast('error', 'Unlock failed. Master password is incorrect.'); + } + } + + function handleLock() { + if (!session) return; + const nextSession = { ...session }; + delete nextSession.symEncKey; + delete nextSession.symMacKey; + setSession(nextSession); + setPhase('locked'); + navigate('/lock'); + } + + function handleLogout() { + setConfirm({ + title: 'Log Out', + message: 'Are you sure you want to log out?', + onConfirm: () => { + setConfirm(null); + setSession(null); + setProfile(null); + setPendingTotp(null); + setPhase(setupRegistered ? 'login' : 'register'); + navigate('/login'); + }, + }); + } + + const ciphersQuery = useQuery({ + queryKey: ['ciphers', session?.accessToken], + queryFn: () => getCiphers(authedFetch), + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, + }); + const foldersQuery = useQuery({ + queryKey: ['folders', session?.accessToken], + queryFn: () => getFolders(authedFetch), + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, + }); + const usersQuery = useQuery({ + queryKey: ['admin-users', session?.accessToken], + queryFn: () => listAdminUsers(authedFetch), + enabled: phase === 'app' && profile?.role === 'admin', + }); + const invitesQuery = useQuery({ + queryKey: ['admin-invites', session?.accessToken], + queryFn: () => listAdminInvites(authedFetch), + enabled: phase === 'app' && profile?.role === 'admin', + }); + + useEffect(() => { + if (!session?.symEncKey || !session?.symMacKey) { + setDecryptedFolders([]); + setDecryptedCiphers([]); + return; + } + if (!foldersQuery.data || !ciphersQuery.data) return; + + let active = true; + (async () => { + try { + const encKey = base64ToBytes(session.symEncKey!); + const macKey = base64ToBytes(session.symMacKey!); + + const folders = await Promise.all( + foldersQuery.data.map(async (folder) => ({ + ...folder, + decName: await decryptStr(folder.name, encKey, macKey), + })) + ); + + const ciphers = await Promise.all( + ciphersQuery.data.map(async (cipher) => { + let itemEnc = encKey; + let itemMac = macKey; + if (cipher.key) { + try { + const itemKey = await decryptBw(cipher.key, encKey, macKey); + itemEnc = itemKey.slice(0, 32); + itemMac = itemKey.slice(32, 64); + } catch { + // keep user key when item key decrypt fails + } + } + + const nextCipher: Cipher = { + ...cipher, + decName: await decryptStr(cipher.name || '', itemEnc, itemMac), + decNotes: await decryptStr(cipher.notes || '', itemEnc, itemMac), + }; + if (cipher.login) { + nextCipher.login = { + ...cipher.login, + decUsername: await decryptStr(cipher.login.username || '', itemEnc, itemMac), + decPassword: await decryptStr(cipher.login.password || '', itemEnc, itemMac), + decTotp: await decryptStr(cipher.login.totp || '', itemEnc, itemMac), + uris: await Promise.all( + (cipher.login.uris || []).map(async (u) => ({ + ...u, + decUri: await decryptStr(u.uri || '', itemEnc, itemMac), + })) + ), + }; + } + if (cipher.card) { + nextCipher.card = { + ...cipher.card, + decCardholderName: await decryptStr(cipher.card.cardholderName || '', itemEnc, itemMac), + decNumber: await decryptStr(cipher.card.number || '', itemEnc, itemMac), + decBrand: await decryptStr(cipher.card.brand || '', itemEnc, itemMac), + decExpMonth: await decryptStr(cipher.card.expMonth || '', itemEnc, itemMac), + decExpYear: await decryptStr(cipher.card.expYear || '', itemEnc, itemMac), + decCode: await decryptStr(cipher.card.code || '', itemEnc, itemMac), + }; + } + if (cipher.identity) { + nextCipher.identity = { + ...cipher.identity, + decTitle: await decryptStr(cipher.identity.title || '', itemEnc, itemMac), + decFirstName: await decryptStr(cipher.identity.firstName || '', itemEnc, itemMac), + decMiddleName: await decryptStr(cipher.identity.middleName || '', itemEnc, itemMac), + decLastName: await decryptStr(cipher.identity.lastName || '', itemEnc, itemMac), + decUsername: await decryptStr(cipher.identity.username || '', itemEnc, itemMac), + decCompany: await decryptStr(cipher.identity.company || '', itemEnc, itemMac), + decSsn: await decryptStr(cipher.identity.ssn || '', itemEnc, itemMac), + decPassportNumber: await decryptStr(cipher.identity.passportNumber || '', itemEnc, itemMac), + decLicenseNumber: await decryptStr(cipher.identity.licenseNumber || '', itemEnc, itemMac), + decEmail: await decryptStr(cipher.identity.email || '', itemEnc, itemMac), + decPhone: await decryptStr(cipher.identity.phone || '', itemEnc, itemMac), + decAddress1: await decryptStr(cipher.identity.address1 || '', itemEnc, itemMac), + decAddress2: await decryptStr(cipher.identity.address2 || '', itemEnc, itemMac), + decAddress3: await decryptStr(cipher.identity.address3 || '', itemEnc, itemMac), + decCity: await decryptStr(cipher.identity.city || '', itemEnc, itemMac), + decState: await decryptStr(cipher.identity.state || '', itemEnc, itemMac), + decPostalCode: await decryptStr(cipher.identity.postalCode || '', itemEnc, itemMac), + decCountry: await decryptStr(cipher.identity.country || '', itemEnc, itemMac), + }; + } + if (cipher.sshKey) { + nextCipher.sshKey = { + ...cipher.sshKey, + decPrivateKey: await decryptStr(cipher.sshKey.privateKey || '', itemEnc, itemMac), + decPublicKey: await decryptStr(cipher.sshKey.publicKey || '', itemEnc, itemMac), + decFingerprint: await decryptStr(cipher.sshKey.fingerprint || '', itemEnc, itemMac), + }; + } + if (cipher.fields) { + nextCipher.fields = await Promise.all( + cipher.fields.map(async (field) => ({ + ...field, + decName: await decryptStr(field.name || '', itemEnc, itemMac), + decValue: await decryptStr(field.value || '', itemEnc, itemMac), + })) + ); + } + return nextCipher; + }) + ); + + if (!active) return; + setDecryptedFolders(folders); + setDecryptedCiphers(ciphers); + } catch (error) { + if (!active) return; + pushToast('error', error instanceof Error ? error.message : 'Decrypt failed'); + } + })(); + + return () => { + active = false; + }; + }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]); + + async function saveProfileAction(name: string, email: string) { + try { + const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() }); + setProfile(updated); + pushToast('success', 'Profile updated'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Save profile failed'); + } + } + + async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) { + if (!profile) return; + if (!currentPassword || !nextPassword) { + pushToast('error', 'Current/new password is required'); + return; + } + if (nextPassword.length < 12) { + pushToast('error', 'New password must be at least 12 chars'); + return; + } + if (nextPassword !== nextPassword2) { + pushToast('error', 'New passwords do not match'); + return; + } + try { + await changeMasterPassword(authedFetch, { + email: profile.email, + currentPassword, + newPassword: nextPassword, + currentIterations: defaultKdfIterations, + profileKey: profile.key, + }); + handleLogout(); + pushToast('success', 'Master password changed. Please login again.'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Change password failed'); + } + } + + async function enableTotpAction(secret: string, token: string) { + if (!secret.trim() || !token.trim()) { + pushToast('error', 'Secret and code are required'); + return; + } + try { + await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); + pushToast('success', 'TOTP enabled'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Enable TOTP failed'); + } + } + + async function disableTotpAction() { + if (!profile) return; + if (!disableTotpPassword) { + pushToast('error', 'Please input master password'); + return; + } + try { + const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); + await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); + setDisableTotpOpen(false); + setDisableTotpPassword(''); + pushToast('success', 'TOTP disabled'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Disable TOTP failed'); + } + } + + async function refreshVault() { + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Vault synced'); + } + + async function createVaultItem(draft: VaultDraft) { + if (!session) return; + try { + await createCipher(authedFetch, session, draft); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Item created'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Create item failed'); + throw error; + } + } + + async function updateVaultItem(cipher: Cipher, draft: VaultDraft) { + if (!session) return; + try { + await updateCipher(authedFetch, session, cipher, draft); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Item updated'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Update item failed'); + throw error; + } + } + + async function deleteVaultItem(cipher: Cipher) { + try { + await deleteCipher(authedFetch, cipher.id); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Item deleted'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Delete item failed'); + throw error; + } + } + + async function bulkDeleteVaultItems(ids: string[]) { + try { + for (const id of ids) { + await deleteCipher(authedFetch, id); + } + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Deleted selected items'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Bulk delete failed'); + throw error; + } + } + + async function bulkMoveVaultItems(ids: string[], folderId: string | null) { + try { + await bulkMoveCiphers(authedFetch, ids, folderId); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', 'Moved selected items'); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : 'Bulk move failed'); + throw error; + } + } + + useEffect(() => { + if (phase === 'app' && location === '/') navigate('/vault'); + }, [phase, location, navigate]); + + if (phase === 'loading') { + return ( + <> +
    Loading NodeWarden...
    + setToasts((prev) => prev.filter((x) => x.id !== id))} /> + + ); + } + + if (phase === 'register' || phase === 'login' || phase === 'locked') { + return ( + <> + void handleLogin()} + onSubmitRegister={() => void handleRegister()} + onSubmitUnlock={() => void handleUnlock()} + onGotoLogin={() => setPhase('login')} + onGotoRegister={() => setPhase('register')} + onLogout={handleLogout} + /> + setToasts((prev) => prev.filter((x) => x.id !== id))} /> + + void handleTotpVerify()} + onCancel={() => { + setPendingTotp(null); + setTotpCode(''); + }} + > + + + + ); + } + + return ( + <> +
    +
    +
    NodeWarden
    + +
    + {profile?.email} + + +
    +
    +
    + + + + + + {profile && ( + setDisableTotpOpen(true)} + /> + )} + + + { + void usersQuery.refetch(); + void invitesQuery.refetch(); + }} + onCreateInvite={async (hours) => { + await createInvite(authedFetch, hours); + await invitesQuery.refetch(); + pushToast('success', 'Invite created'); + }} + onToggleUserStatus={async (userId, status) => { + await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); + await usersQuery.refetch(); + pushToast('success', 'User status updated'); + }} + onDeleteUser={async (userId) => { + setConfirm({ + title: 'Delete user', + message: 'Delete this user and all user data?', + danger: true, + onConfirm: () => { + setConfirm(null); + void (async () => { + await deleteUser(authedFetch, userId); + await usersQuery.refetch(); + pushToast('success', 'User deleted'); + })(); + }, + }); + }} + onRevokeInvite={async (code) => { + await revokeInvite(authedFetch, code); + await invitesQuery.refetch(); + pushToast('success', 'Invite revoked'); + }} + /> + + + + + +
    +
    + + confirm?.onConfirm()} + onCancel={() => setConfirm(null)} + /> + + void disableTotpAction()} + onCancel={() => { + setDisableTotpOpen(false); + setDisableTotpPassword(''); + }} + > + + + + setToasts((prev) => prev.filter((x) => x.id !== id))} /> + + ); +} diff --git a/webapp/src/components/AdminPage.tsx b/webapp/src/components/AdminPage.tsx new file mode 100644 index 0000000..3284e5a --- /dev/null +++ b/webapp/src/components/AdminPage.tsx @@ -0,0 +1,116 @@ +import { useState } from 'preact/hooks'; +import type { AdminInvite, AdminUser } from '@/lib/types'; + +interface AdminPageProps { + users: AdminUser[]; + invites: AdminInvite[]; + onRefresh: () => void; + onCreateInvite: (hours: number) => Promise; + onToggleUserStatus: (userId: string, currentStatus: string) => Promise; + onDeleteUser: (userId: string) => Promise; + onRevokeInvite: (code: string) => Promise; +} + +export default function AdminPage(props: AdminPageProps) { + const [inviteHours, setInviteHours] = useState(168); + + return ( +
    +
    +
    +

    Invites

    + +
    +
    + setInviteHours(Number((e.currentTarget as HTMLInputElement).value || 168))} + /> + +
    + + + + + + + + + + {props.invites.map((invite) => ( + + + + + + ))} + +
    CodeStatusActions
    {invite.code}{invite.status} +
    + + {invite.status === 'active' && ( + + )} +
    +
    +
    + +
    +

    Users

    + + + + + + + + + + + + {props.users.map((user) => ( + + + + + + + + ))} + +
    EmailNameRoleStatusActions
    {user.email}{user.name || '-'}{user.role}{user.status} +
    + + {user.role !== 'admin' && ( + + )} +
    +
    +
    +
    + ); +} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx new file mode 100644 index 0000000..900dfbb --- /dev/null +++ b/webapp/src/components/AuthViews.tsx @@ -0,0 +1,173 @@ +import { useState } from 'preact/hooks'; + +interface LoginValues { + email: string; + password: string; +} + +interface RegisterValues { + name: string; + email: string; + password: string; + password2: string; + inviteCode: string; +} + +interface AuthViewsProps { + mode: 'login' | 'register' | 'locked'; + loginValues: LoginValues; + registerValues: RegisterValues; + unlockPassword: string; + emailForLock: string; + onChangeLogin: (next: LoginValues) => void; + onChangeRegister: (next: RegisterValues) => void; + onChangeUnlock: (password: string) => void; + onSubmitLogin: () => void; + onSubmitRegister: () => void; + onSubmitUnlock: () => void; + onGotoLogin: () => void; + onGotoRegister: () => void; + onLogout: () => void; +} + +function PasswordField(props: { + label: string; + value: string; + onInput: (v: string) => void; + autoFocus?: boolean; +}) { + const [show, setShow] = useState(false); + return ( + + ); +} + +export default function AuthViews(props: AuthViewsProps) { + if (props.mode === 'locked') { + return ( +
    +
    +

    Unlock Vault

    +

    {props.emailForLock}

    + + +
    or
    + +
    +
    + ); + } + + if (props.mode === 'register') { + return ( +
    +
    +

    Create Account

    +

    NodeWarden

    + + + props.onChangeRegister({ ...props.registerValues, password: v })} + /> + props.onChangeRegister({ ...props.registerValues, password2: v })} + /> + + +
    or
    + +
    +
    + ); + } + + return ( +
    +
    +

    Log In

    +

    NodeWarden

    + + props.onChangeLogin({ ...props.loginValues, password: v })} + autoFocus + /> + +
    or
    + +
    +
    + ); +} diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..e179007 --- /dev/null +++ b/webapp/src/components/ConfirmDialog.tsx @@ -0,0 +1,37 @@ +import type { ComponentChildren } from 'preact'; + +interface ConfirmDialogProps { + open: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; + children?: ComponentChildren; +} + +export default function ConfirmDialog(props: ConfirmDialogProps) { + if (!props.open) return null; + return ( +
    +
    +
    !
    +

    {props.title}

    +
    {props.message}
    + {props.children} + + +
    +
    + ); +} diff --git a/webapp/src/components/HelpPage.tsx b/webapp/src/components/HelpPage.tsx new file mode 100644 index 0000000..45b2845 --- /dev/null +++ b/webapp/src/components/HelpPage.tsx @@ -0,0 +1,23 @@ +export default function HelpPage() { + return ( +
    +
    +

    Upstream Sync

    +
      +
    • Use fork + scheduled sync workflow.
    • +
    • Before merging, compare API routes and auth flow changes.
    • +
    • After merging, run migration tests in local dev before deploy.
    • +
    +
    +
    +

    Common Errors

    +
      +
    • 401 Unauthorized: token expired, log in again.
    • +
    • 403 Account disabled: admin must unban your account.
    • +
    • 403 Invite invalid: invite expired or revoked.
    • +
    • 429 Too many requests: wait and retry.
    • +
    +
    +
    + ); +} diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx new file mode 100644 index 0000000..e2670df --- /dev/null +++ b/webapp/src/components/SettingsPage.tsx @@ -0,0 +1,128 @@ +import { useMemo, useState } from 'preact/hooks'; +import qrcode from 'qrcode-generator'; +import type { Profile } from '@/lib/types'; + +interface SettingsPageProps { + profile: Profile; + onSaveProfile: (name: string, email: string) => Promise; + onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; + onEnableTotp: (secret: string, token: string) => Promise; + onOpenDisableTotp: () => void; +} + +function randomBase32Secret(length: number): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const random = crypto.getRandomValues(new Uint8Array(length)); + let out = ''; + for (const x of random) out += alphabet[x % alphabet.length]; + return out; +} + +function buildOtpUri(email: string, secret: string): string { + const issuer = 'NodeWarden'; + return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; +} + +export default function SettingsPage(props: SettingsPageProps) { + const [name, setName] = useState(props.profile.name || ''); + const [email, setEmail] = useState(props.profile.email || ''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [newPassword2, setNewPassword2] = useState(''); + const [secret, setSecret] = useState(randomBase32Secret(32)); + const [token, setToken] = useState(''); + + const qrSvg = useMemo(() => { + const qr = qrcode(0, 'M'); + qr.addData(buildOtpUri(email || props.profile.email, secret)); + qr.make(); + return qr.createSvgTag({ scalable: true, margin: 0 }); + }, [email, props.profile.email, secret]); + + return ( +
    +
    +

    Profile

    +
    + + +
    + +
    + +
    +

    Change Master Password

    + +
    + + +
    + +
    + +
    +

    TOTP

    +
    +
    +
    + + +
    + + + +
    +
    +
    + +
    +
    + ); +} diff --git a/webapp/src/components/ToastHost.tsx b/webapp/src/components/ToastHost.tsx new file mode 100644 index 0000000..3c5ef0b --- /dev/null +++ b/webapp/src/components/ToastHost.tsx @@ -0,0 +1,23 @@ +import type { ToastMessage } from '@/lib/types'; + +interface ToastHostProps { + toasts: ToastMessage[]; + onClose: (id: string) => void; +} + +export default function ToastHost({ toasts, onClose }: ToastHostProps) { + if (!toasts.length) return null; + return ( +
      + {toasts.map((toast) => ( +
    • +
      {toast.text}
      + +
      +
    • + ))} +
    + ); +} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx new file mode 100644 index 0000000..426817a --- /dev/null +++ b/webapp/src/components/VaultPage.tsx @@ -0,0 +1,1045 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import ConfirmDialog from '@/components/ConfirmDialog'; +import { calcTotpNow } from '@/lib/crypto'; +import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; + +interface VaultPageProps { + ciphers: Cipher[]; + folders: Folder[]; + loading: boolean; + onRefresh: () => Promise; + onCreate: (draft: VaultDraft) => Promise; + onUpdate: (cipher: Cipher, draft: VaultDraft) => Promise; + onDelete: (cipher: Cipher) => Promise; + onBulkDelete: (ids: string[]) => Promise; + onBulkMove: (ids: string[], folderId: string | null) => Promise; +} + +type TypeFilter = 'all' | 'favorite' | 'login' | 'card' | 'identity' | 'note' | 'ssh'; + +interface TypeOption { + type: number; + label: string; +} + +const CREATE_TYPE_OPTIONS: TypeOption[] = [ + { type: 1, label: 'Login' }, + { type: 3, label: 'Card' }, + { type: 4, label: 'Identity' }, + { type: 2, label: 'Note' }, + { type: 5, label: 'SSH Key' }, +]; + +const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ + { value: 0, label: 'Text' }, + { value: 1, label: 'Hidden' }, + { value: 2, label: 'Boolean' }, + { value: 3, label: 'Linked' }, +]; + +function cipherTypeKey(type: number): TypeFilter { + if (type === 1) return 'login'; + if (type === 3) return 'card'; + if (type === 4) return 'identity'; + if (type === 2) return 'note'; + return 'ssh'; +} + +function cipherTypeLabel(type: number): string { + if (type === 1) return 'Login'; + if (type === 3) return 'Card'; + if (type === 4) return 'Identity'; + if (type === 2) return 'Secure Note'; + if (type === 5) return 'SSH Key'; + return 'Item'; +} + +function typeIconText(type: number): string { + if (type === 1) return 'L'; + if (type === 3) return 'C'; + if (type === 4) return 'I'; + if (type === 2) return 'N'; + if (type === 5) return 'S'; + return 'V'; +} + +function parseFieldType(value: number | string | null | undefined): CustomFieldType { + if (value === 1 || value === 2 || value === 3) return value; + if (value === '1' || String(value).toLowerCase() === 'hidden') return 1; + if (value === '2' || String(value).toLowerCase() === 'boolean') return 2; + if (value === '3' || String(value).toLowerCase() === 'linked') return 3; + return 0; +} + +function fieldTypeLabel(type: CustomFieldType): string { + const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type); + return found ? found.label : 'Text'; +} + +function firstCipherUri(cipher: Cipher): string { + const uris = cipher.login?.uris || []; + for (const uri of uris) { + const raw = uri.decUri || uri.uri || ''; + if (raw.trim()) return raw.trim(); + } + return ''; +} + +function hostFromUri(uri: string): string { + if (!uri.trim()) return ''; + try { + const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; + return new URL(normalized).hostname || ''; + } catch { + return ''; + } +} + +function createEmptyDraft(type: number): VaultDraft { + return { + type, + name: '', + folderId: '', + notes: '', + reprompt: false, + loginUsername: '', + loginPassword: '', + loginTotp: '', + loginUris: [''], + cardholderName: '', + cardNumber: '', + cardBrand: '', + cardExpMonth: '', + cardExpYear: '', + cardCode: '', + identTitle: '', + identFirstName: '', + identMiddleName: '', + identLastName: '', + identUsername: '', + identCompany: '', + identSsn: '', + identPassportNumber: '', + identLicenseNumber: '', + identEmail: '', + identPhone: '', + identAddress1: '', + identAddress2: '', + identAddress3: '', + identCity: '', + identState: '', + identPostalCode: '', + identCountry: '', + sshPrivateKey: '', + sshPublicKey: '', + sshFingerprint: '', + customFields: [], + }; +} + +function draftFromCipher(cipher: Cipher): VaultDraft { + const draft = createEmptyDraft(Number(cipher.type || 1)); + draft.id = cipher.id; + draft.name = cipher.decName || ''; + draft.folderId = cipher.folderId || ''; + draft.notes = cipher.decNotes || ''; + draft.reprompt = Number(cipher.reprompt || 0) === 1; + + if (cipher.login) { + draft.loginUsername = cipher.login.decUsername || ''; + draft.loginPassword = cipher.login.decPassword || ''; + draft.loginTotp = cipher.login.decTotp || ''; + draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); + if (!draft.loginUris.length) draft.loginUris = ['']; + } + if (cipher.card) { + draft.cardholderName = cipher.card.decCardholderName || ''; + draft.cardNumber = cipher.card.decNumber || ''; + draft.cardBrand = cipher.card.decBrand || ''; + draft.cardExpMonth = cipher.card.decExpMonth || ''; + draft.cardExpYear = cipher.card.decExpYear || ''; + draft.cardCode = cipher.card.decCode || ''; + } + if (cipher.identity) { + draft.identTitle = cipher.identity.decTitle || ''; + draft.identFirstName = cipher.identity.decFirstName || ''; + draft.identMiddleName = cipher.identity.decMiddleName || ''; + draft.identLastName = cipher.identity.decLastName || ''; + draft.identUsername = cipher.identity.decUsername || ''; + draft.identCompany = cipher.identity.decCompany || ''; + draft.identSsn = cipher.identity.decSsn || ''; + draft.identPassportNumber = cipher.identity.decPassportNumber || ''; + draft.identLicenseNumber = cipher.identity.decLicenseNumber || ''; + draft.identEmail = cipher.identity.decEmail || ''; + draft.identPhone = cipher.identity.decPhone || ''; + draft.identAddress1 = cipher.identity.decAddress1 || ''; + draft.identAddress2 = cipher.identity.decAddress2 || ''; + draft.identAddress3 = cipher.identity.decAddress3 || ''; + draft.identCity = cipher.identity.decCity || ''; + draft.identState = cipher.identity.decState || ''; + draft.identPostalCode = cipher.identity.decPostalCode || ''; + draft.identCountry = cipher.identity.decCountry || ''; + } + if (cipher.sshKey) { + draft.sshPrivateKey = cipher.sshKey.decPrivateKey || ''; + draft.sshPublicKey = cipher.sshKey.decPublicKey || ''; + draft.sshFingerprint = cipher.sshKey.decFingerprint || ''; + } + draft.customFields = (cipher.fields || []).map((field) => ({ + type: parseFieldType(field.type), + label: field.decName || '', + value: field.decValue || '', + })); + + return draft; +} + +function matchesTypeFilter(cipher: Cipher, typeFilter: TypeFilter): boolean { + if (typeFilter === 'all') return true; + if (typeFilter === 'favorite') return !!cipher.favorite; + return cipherTypeKey(Number(cipher.type || 1)) === typeFilter; +} + +function maskSecret(value: string): string { + if (!value) return ''; + return '*'.repeat(Math.max(8, Math.min(24, value.length))); +} + +function formatTotp(code: string): string { + if (!code || code.length < 6) return code; + return `${code.slice(0, 3)} ${code.slice(3, 6)}`; +} + +function VaultListIcon({ cipher }: { cipher: Cipher }) { + const uri = firstCipherUri(cipher); + const host = hostFromUri(uri); + const [errored, setErrored] = useState(false); + if (host && !errored) { + return ( + setErrored(true)} + /> + ); + } + return {typeIconText(Number(cipher.type || 1))}; +} + +function copyToClipboard(value: string): void { + if (!value.trim()) return; + void navigator.clipboard.writeText(value); +} + +function openUri(raw: string): void { + const value = raw.trim(); + if (!value) return; + const url = /^https?:\/\//i.test(value) ? value : `https://${value}`; + window.open(url, '_blank', 'noopener'); +} + +export default function VaultPage(props: VaultPageProps) { + const [searchInput, setSearchInput] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [searchComposing, setSearchComposing] = useState(false); + const [typeFilter, setTypeFilter] = useState('all'); + const [folderFilter, setFolderFilter] = useState('all'); + const [selectedCipherId, setSelectedCipherId] = useState(''); + const [selectedMap, setSelectedMap] = useState>({}); + const [showPassword, setShowPassword] = useState(false); + const [createMenuOpen, setCreateMenuOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [draft, setDraft] = useState(null); + const [fieldModalOpen, setFieldModalOpen] = useState(false); + const [fieldType, setFieldType] = useState(0); + const [fieldLabel, setFieldLabel] = useState(''); + const [fieldValue, setFieldValue] = useState(''); + const [localError, setLocalError] = useState(''); + const [pendingDelete, setPendingDelete] = useState(null); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [moveOpen, setMoveOpen] = useState(false); + const [moveFolderId, setMoveFolderId] = useState('__none__'); + const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (searchComposing) return; + const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90); + return () => window.clearTimeout(timer); + }, [searchInput, searchComposing]); + + const filteredCiphers = useMemo(() => { + return props.ciphers.filter((cipher) => { + if (!matchesTypeFilter(cipher, typeFilter)) return false; + if (folderFilter === 'none' && cipher.folderId) return false; + if (folderFilter !== 'none' && folderFilter !== 'all' && cipher.folderId !== folderFilter) return false; + if (!searchQuery) return true; + const name = (cipher.decName || '').toLowerCase(); + const username = (cipher.login?.decUsername || '').toLowerCase(); + const uri = firstCipherUri(cipher).toLowerCase(); + return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery); + }); + }, [props.ciphers, folderFilter, typeFilter, searchQuery]); + + useEffect(() => { + if (isCreating) return; + if (!filteredCiphers.length) { + if (selectedCipherId) setSelectedCipherId(''); + return; + } + if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) { + setSelectedCipherId(filteredCiphers[0].id); + } + }, [filteredCiphers, selectedCipherId, isCreating]); + + const selectedCipher = useMemo( + () => props.ciphers.find((x) => x.id === selectedCipherId) || null, + [props.ciphers, selectedCipherId] + ); + + useEffect(() => { + const raw = selectedCipher?.login?.decTotp || ''; + if (!raw) { + setTotpLive(null); + return; + } + let stopped = false; + let timer = 0; + const tick = async () => { + try { + const now = await calcTotpNow(raw); + if (!stopped) setTotpLive(now); + } catch { + if (!stopped) setTotpLive(null); + } + }; + void tick(); + timer = window.setInterval(() => void tick(), 1000); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [selectedCipher?.id, selectedCipher?.login?.decTotp]); + + const selectedCount = useMemo( + () => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0), + [selectedMap] + ); + + function folderName(id: string | null | undefined): string { + if (!id) return 'No Folder'; + const folder = props.folders.find((x) => x.id === id); + return folder?.decName || folder?.name || id; + } + + function listSubtitle(cipher: Cipher): string { + if (Number(cipher.type || 1) === 1) { + return cipher.login?.decUsername || firstCipherUri(cipher) || ''; + } + return cipherTypeLabel(Number(cipher.type || 1)); + } + + function startCreate(type: number): void { + setDraft(createEmptyDraft(type)); + setIsCreating(true); + setIsEditing(true); + setCreateMenuOpen(false); + setSelectedCipherId(''); + setShowPassword(false); + setLocalError(''); + } + + function startEdit(): void { + if (!selectedCipher) return; + setDraft(draftFromCipher(selectedCipher)); + setIsCreating(false); + setIsEditing(true); + setShowPassword(false); + setLocalError(''); + } + + function cancelEdit(): void { + setDraft(null); + setIsEditing(false); + setIsCreating(false); + setLocalError(''); + } + + function updateDraft(patch: Partial): void { + setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + } + + function updateDraftCustomFields(nextFields: VaultDraftField[]): void { + setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev)); + } + + function updateDraftLoginUri(index: number, value: string): void { + setDraft((prev) => { + if (!prev) return prev; + const next = [...prev.loginUris]; + next[index] = value; + return { ...prev, loginUris: next }; + }); + } + + async function saveDraft(): Promise { + if (!draft) return; + if (!draft.name.trim()) { + setLocalError('Item name is required.'); + return; + } + setBusy(true); + try { + if (isCreating) { + await props.onCreate(draft); + } else if (selectedCipher) { + await props.onUpdate(selectedCipher, draft); + } + setIsCreating(false); + setIsEditing(false); + setDraft(null); + setLocalError(''); + } finally { + setBusy(false); + } + } + + async function deleteSelected(): Promise { + if (!pendingDelete) return; + setBusy(true); + try { + await props.onDelete(pendingDelete); + setPendingDelete(null); + cancelEdit(); + } finally { + setBusy(false); + } + } + + async function confirmBulkDelete(): Promise { + const ids = Object.entries(selectedMap) + .filter(([, selected]) => selected) + .map(([id]) => id); + if (!ids.length) return; + setBusy(true); + try { + await props.onBulkDelete(ids); + setSelectedMap({}); + setBulkDeleteOpen(false); + } finally { + setBusy(false); + } + } + + async function confirmBulkMove(): Promise { + const ids = Object.entries(selectedMap) + .filter(([, selected]) => selected) + .map(([id]) => id); + if (!ids.length) return; + const folderId = moveFolderId === '__none__' ? null : moveFolderId; + setBusy(true); + try { + await props.onBulkMove(ids, folderId); + setSelectedMap({}); + setMoveOpen(false); + } finally { + setBusy(false); + } + } + + async function syncVault(): Promise { + setBusy(true); + try { + await props.onRefresh(); + } finally { + setBusy(false); + } + } + + return ( + <> +
    + + +
    +
    + + + + + +
    + + {createMenuOpen && ( +
    + {CREATE_TYPE_OPTIONS.map((option) => ( + + ))} +
    + )} +
    +
    + +
    + {filteredCiphers.map((cipher) => ( +
    + + setSelectedMap((prev) => ({ + ...prev, + [cipher.id]: (e.currentTarget as HTMLInputElement).checked, + })) + } + /> + +
    + ))} + {!filteredCiphers.length &&
    No items
    } +
    +
    + +
    + {isEditing && draft && ( + <> +
    +

    {isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}

    +
    + + +
    + +
    + + {draft.type === 1 && ( +
    +

    Login Credentials

    +
    + + +
    + +
    +

    Websites

    + +
    + {draft.loginUris.map((uri, index) => ( +
    + updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> + {draft.loginUris.length > 1 && ( + + )} +
    + ))} +
    + )} + + {draft.type === 3 && ( +
    +

    Card Details

    +
    + + + + + + +
    +
    + )} + + {draft.type === 4 && ( +
    +

    Identity Details

    +
    + + + + + + + + + + + + + + + + + + +
    +
    + )} + {draft.type === 5 && ( +
    +

    SSH Key

    +