From f1b716fb316c24d2624f03c11fe77de7c6e3358b Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sat, 20 Jun 2026 00:01:36 +0800 Subject: [PATCH 001/157] chore: update .gitignore file --- .codegraph/.gitignore | 22 +- .gitignore | 1 + docs/passkey-login-research.md | 785 --------------------------------- 3 files changed, 6 insertions(+), 802 deletions(-) delete mode 100644 docs/passkey-login-research.md diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore index 2c9c938..d20c0fe 100644 --- a/.codegraph/.gitignore +++ b/.codegraph/.gitignore @@ -1,17 +1,5 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty -*.pid +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/.gitignore b/.gitignore index b83ab26..71246f7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ build/ .idea/ *.swp *.swo +docs/ # OS .DS_Store diff --git a/docs/passkey-login-research.md b/docs/passkey-login-research.md deleted file mode 100644 index 15780a7..0000000 --- a/docs/passkey-login-research.md +++ /dev/null @@ -1,785 +0,0 @@ -# NodeWarden Passkey 登录研究记录 - -记录日期:2026-06-09 -研究范围:NodeWarden 自己的 server、web 登录/注册链路,以及官方 Bitwarden server、web、browser extension 对账户 passkey 登录的实现方式。 - -## 结论先放前面 - -NodeWarden 现在已经有完整的主密码注册、主密码登录、刷新 token、2FA、设备记录、官方客户端兼容的 `UserDecryptionOptions`,也支持 vault item 里的 `login.fido2Credentials` 字段。但它还没有“账户 passkey 登录”。现有 `src/utils/passkey.ts` 只有 base64url、challenge、clientData 解析这类工具函数,不能完成 FIDO2/WebAuthn 服务端注册和认证验证。 - -要支持“自己的 web 用 passkey 登录”和“官方/自定义浏览器扩展也能 passkey 登录”,不能只加一个登录按钮。必须补齐四块: - -1. Server 端新增账户 WebAuthn credential 表、challenge/token 防重放机制、FIDO2 attestation/assertion 验证、`grant_type=webauthn`。 -2. Server 响应里按 Bitwarden 形状返回 PRF 解密材料:登录 token 响应用单个 `UserDecryptionOptions.WebAuthnPrfOption`,sync 响应用多个 `UserDecryption.WebAuthnPrfOptions`。 -3. NodeWarden web 新增 passkey 注册、管理、登录和 PRF 解锁 vault key 的客户端流程。 -4. 扩展兼容要跟官方 Bitwarden endpoint 和 response shape 对齐。官方 browser extension 当前只在 Chromium 系浏览器开放 passkey 登录,因为 Firefox/Safari 扩展环境还不能按官方代码需要的方式覆盖 RP ID。 - -下面按代码链路展开。 - -## 术语边界 - -这里有三个容易混淆的东西,文档后面严格区分: - -- 账户 passkey 登录:用户不用主密码,使用 WebAuthn/passkey 完成账号认证,并且用 PRF 解开 vault user key。官方 Bitwarden 叫 `WebAuthnLogin`。 -- Vault item 里的 passkey:某个登录条目保存网站 passkey/FIDO2 credential 数据,对应 NodeWarden 的 `cipher.login.fido2Credentials`。这是“保险库保存别的网站 passkey”,不是“登录 NodeWarden 账号”。 -- WebAuthn 2FA:主密码登录之后用安全密钥做第二因素。官方旧 web repo 里主要是这一类,不等于 passkey 登录。 - -## NodeWarden 现状 - -### 路由和入口 - -NodeWarden 后端是 Cloudflare Workers + D1。主入口 `src/index.ts` 初始化存储后进入 router。认证边界在: - -- `src/router-public.ts`:公开接口,包含 `/identity/connect/token`、`/identity/accounts/prelogin`、`/api/accounts/register`。 -- `src/router-authenticated.ts`:需要 access token 的接口,包含 profile、change password、TOTP、sync、vault、devices。 -- `src/handlers/identity.ts`:OAuth/token 兼容入口。 -- `src/handlers/accounts.ts`:注册、profile、密码变更、TOTP、API key 等账户接口。 - -目前公开路由没有: - -- `GET /identity/accounts/webauthn/assertion-options` -- `POST /identity/connect/token` 的 `grant_type=webauthn` -- `POST /api/webauthn/attestation-options` -- `POST /api/webauthn/assertion-options` -- `GET/POST/PUT /api/webauthn` - -### 注册链路 - -NodeWarden 自己 web 的注册入口在 `webapp/src/lib/api/auth.ts` 的 `registerAccount()`: - -- 使用邮箱作为 salt,用 PBKDF2 派生 master key。 -- 再用 PBKDF2(masterKey, password, 1) 得到 client master password hash。 -- 随机生成 64 字节 vault symmetric key。 -- 用 masterKey 经 HKDF 拆成 enc/mac,把 vault key 加密成 Bitwarden `Key`。 -- 生成 RSA-OAEP key pair,把 private key 用 vault symmetric key 加密。 -- POST `/api/accounts/register`,提交 `email`、`name`、`masterPasswordHash`、`key`、KDF 参数、invite code、`keys.publicKey`、`keys.encryptedPrivateKey`。 - -后端 `src/handlers/accounts.ts` 的 `handleRegister()`: - -- 第一个用户自动成为 admin,后续用户需要 invite。 -- 校验 `JWT_SECRET`、邮箱、KDF 下限、加密字符串形状、公钥/私钥。 -- 不直接保存 client hash,而是 `AuthService.hashPasswordServer(masterPasswordHash, email)` 后保存到 `users.master_password_hash`。 -- 保存 `users.key`、`users.private_key`、`users.public_key`、KDF 参数、`security_stamp`。 - -结论:账户 passkey 注册不是替代账号注册,而是“用户已登录后在安全设置里新增一个可登录 credential”。仍然需要已有 vault user key 来生成 PRF keyset。 - -### 主密码登录链路 - -NodeWarden 自己 web 的登录入口是 `webapp/src/lib/app-auth.ts` 的 `performPasswordLogin()`: - -- 先 `deriveLoginHashLocally()` 得到 masterKey 和 client hash。 -- 调 `loginWithPassword()` POST `/identity/connect/token`。 -- token 成功后 `completeLogin()` 用 `token.Key` 和本地 masterKey 解开 vault key。 -- 保存离线解锁记录。 - -`webapp/src/lib/api/auth.ts` 也有 `deriveLoginHash()` 和 `getPreloginKdfConfig()` 会调用 `/identity/accounts/prelogin`,但当前 `performPasswordLogin()` 走的是本地 fallback iterations。passkey 登录不应复用这条 masterKey 路径,因为 passkey 登录没有主密码,拿不到 password-derived masterKey。 - -后端 `src/handlers/identity.ts` 的 `handleToken()` 当前支持: - -- `grant_type=password` -- `grant_type=client_credentials` -- `grant_type=refresh_token` - -密码登录成功后会: - -- 验证 IP 登录频率和用户状态。 -- `AuthService.verifyPassword()` 验证 client hash。 -- 处理 TOTP 或 remember 2FA token。 -- 记录/更新 device。 -- 生成 access token 和 refresh token。 -- 返回 `Key`、`PrivateKey`、`AccountKeys`、KDF 参数、`UserDecryptionOptions`。 - -### UserDecryptionOptions 和 sync - -NodeWarden 的 `src/utils/user-decryption.ts` 当前只构造主密码解锁: - -- `HasMasterPassword: true` -- `MasterPasswordUnlock` -- `TrustedDeviceOption: null` -- `KeyConnectorOption: null` - -`src/types/index.ts` 的 sync 类型里预留了 `UserDecryption.WebAuthnPrfOption?: null`,但当前 `src/handlers/sync.ts` 实际只返回 `MasterPasswordUnlock`,没有账户 passkey PRF 解密选项。 - -passkey 登录必须新增两类 shape: - -- 登录 token 响应:`UserDecryptionOptions.WebAuthnPrfOption`,只返回本次认证所用 credential 的 PRF 解密材料。 -- sync 响应:`UserDecryption.WebAuthnPrfOptions`,返回该用户所有已启用 PRF keyset 的 passkey 解密材料,供官方客户端锁定/解锁和 key rotation 使用。 - -### 现有 passkey 相关代码 - -NodeWarden 已支持 vault item 里的 FIDO2/passkey 字段: - -- `src/types/index.ts`:`CipherLogin.fido2Credentials` -- `src/handlers/ciphers.ts`:读写 cipher 时保留/规范化 `fido2Credentials` -- `webapp/src/lib/api/vault.ts`:加密/解密 vault item 内的 `fido2Credentials` -- `webapp/src/lib/types.ts`:`CipherLoginPasskey` - -这部分是“保存网站 passkey”,不是账户登录。 - -`src/utils/passkey.ts` 只有: - -- `bytesToBase64Url()` -- `base64UrlToBytes()` -- `randomChallenge()` -- `parseClientDataJSON()` - -缺少的核心能力: - -- attestation verification -- assertion verification -- authenticator public key 格式处理 -- signature verification -- sign counter 更新 -- userHandle 与 user id 绑定验证 -- origin/RP ID 验证 -- challenge 过期和防重放 - -### 数据库和备份影响 - -NodeWarden schema 在这些地方需要同步: - -- `migrations/0001_init.sql` -- `src/services/storage-schema.ts` -- `wrangler.toml` migrations -- `src/services/backup-archive.ts` -- `src/services/backup-import.ts` -- `shared/backup-schema` 相关类型 - -当前表里没有账户 passkey credential,也没有 WebAuthn challenge 表。`devices` 表保存设备 trust/key 信息,不适合混入 passkey credential,因为 WebAuthn credential 需要自己的 public key、credential id、counter、AAGUID、PRF keyset 等字段。 - -## 官方 Bitwarden server 参考 - -上游代码位置: - -- `.codex-upstream/bitwarden-server` -- 研究时 HEAD:`574f3fd` - -官方 server 里也有两个 WebAuthn 概念: - -- 传统 WebAuthn 2FA:`TwoFactorController`、`WebAuthnTokenProvider` -- 账户 passkey 登录:`WebAuthnLogin` - -本项目要参考的是后者。 - -### 公开 passkey 登录入口 - -`src/Identity/Controllers/AccountsController.cs` - -- `GET /accounts/webauthn/assertion-options` -- 返回 `WebAuthnLoginAssertionOptionsResponseModel` -- response 包含: - - `options` - - `token` -- token 使用 `WebAuthnLoginAssertionOptionsTokenable` -- scope 为 `Authentication` -- token 生命周期约 17 分钟 - -`src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs` - -- 新增 OAuth extension grant:`grant_type=webauthn` -- 从 form 读取: - - `token` - - `deviceResponse` -- 解开 token,校验 scope 必须是 `Authentication` -- 反序列化 `AuthenticatorAssertionRawResponse` -- 调用 `AssertWebAuthnLoginCredential` -- 把成功认证的 credential 传给 `UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential)` -- 之后走通用登录成功逻辑,返回 access/refresh token 和账号加密状态。 - -`src/Identity/IdentityServer/ApiClient.cs` - -- official identity client 的 allowed grant types 包含 `WebAuthnGrantValidator.GrantType`。 - -`TwoFactorAuthenticationValidator` 里有一个重要行为:FIDO2 user verification 已经被视为第二因素,所以 passkey 登录成功后官方不会再要求额外 2FA。NodeWarden 之后需要明确策略:要兼容官方客户端,应把 passkey 登录视作已满足 2FA,否则官方 `LoginViaWebAuthnComponent` 会显示“不支持 passkey 2FA”的错误。 - -### 账户 passkey 管理接口 - -`src/Api/Auth/Controllers/WebAuthnController.cs` - -官方 authenticated API: - -- `GET /webauthn`:列出账户 passkey credentials。 -- `POST /webauthn/attestation-options`:主密码/secret verification 后生成 credential create options 和 token。 -- `POST /webauthn/assertion-options`:主密码/secret verification 后生成 assertion options 和 token,用于给已有 credential 启用/更新 PRF keyset。 -- `POST /webauthn`:保存新 credential。 -- `PUT /webauthn`:更新 credential 的 PRF encryption keyset。 -- `POST /webauthn/{id}/delete`:删除 credential。 - -官方创建 credential 时保存: - -- `name` -- `token` -- `deviceResponse` -- `supportsPrf` -- 可选 `encryptedUserKey` -- 可选 `encryptedPublicKey` -- 可选 `encryptedPrivateKey` - -官方最多允许 5 个账户 passkey credentials。 - -### 官方 WebAuthnCredential 表 - -`src/Core/Auth/Entities/WebAuthnCredential.cs` - -字段: - -- `Id` -- `UserId` -- `Name` -- `PublicKey` -- `CredentialId` -- `Counter` -- `Type` -- `AaGuid` -- `EncryptedUserKey` -- `EncryptedPrivateKey` -- `EncryptedPublicKey` -- `SupportsPrf` -- `CreationDate` -- `RevisionDate` - -SQLite migration:`util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs` - -表名是 `WebAuthnCredential`,对 `User` 做 cascade delete,并按 `UserId` 建索引。 - -`GetPrfStatus()`: - -- `Unsupported`:`SupportsPrf` 为 false。 -- `Supported`:credential 支持 PRF,但还没有完整 encrypted keyset。 -- `Enabled`:`EncryptedUserKey`、`EncryptedPrivateKey`、`EncryptedPublicKey` 都存在。 - -### 官方创建和认证策略 - -`GetWebAuthnLoginCredentialCreateOptionsCommand.cs` - -- 使用 Fido2NetLib。 -- `user.id` 是用户 id bytes。 -- `user.name/displayName` 使用用户邮箱。 -- 排除当前用户已有 credential ids。 -- `residentKey: required` -- `userVerification: required` -- `attestation: none` - -`GetWebAuthnLoginCredentialAssertionOptionsCommand.cs` - -- `allowCredentials` 传空数组。 -- `userVerification: required` -- 空 allow list 代表使用 discoverable credentials,也就是 passkey 登录页可以不先输入邮箱。 - -`CreateWebAuthnLoginCredentialCommand.cs` - -- 限制每用户最多 5 个。 -- 检查 credential id 在该用户下不能重复。 -- FIDO `MakeNewCredentialAsync` 验证 attestation。 -- 保存 credential id/public key/counter/type/AAGUID/PRF keyset。 - -`AssertWebAuthnLoginCredentialCommand.cs` - -- 先用 challenge cache 防重放。 -- 从 assertion response 的 `userHandle` 解析出 user id。 -- 加载该用户所有 WebAuthn credentials。 -- 用 credential id 找到记录。 -- FIDO `MakeAssertionAsync` 验证签名、challenge、origin、RP ID、user verification。 -- 成功后更新 counter。 - -### 官方 PRF 解密协议 - -`src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs` - -`WebAuthnPrfDecryptionOption` 字段: - -- `EncryptedPrivateKey` -- `EncryptedUserKey` -- `CredentialId` -- `Transports` - -`src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs` - -- `WithWebAuthnLoginCredential()` 只在 credential 的 PRF status 是 `Enabled` 时加入 `WebAuthnPrfOption`。 -- 如果 credential 没有 PRF keyset,passkey 只能认证账号,不能解开 vault。 - -`src/Api/Vault/Models/Response/SyncResponseModel.cs` - -- sync response 会把所有 enabled PRF credentials 放进 `UserDecryption.WebAuthnPrfOptions`。 - -## 官方 Bitwarden web/browser client 参考 - -上游代码位置: - -- `.codex-upstream/bitwarden-clients` -- `.codex-upstream/bitwarden-browser` -- 两者研究时 HEAD 都是 `825f9be`,browser repo 内容和 clients monorepo 对应。 - -旧的 `.codex-upstream/bitwarden-web` 主要有 WebAuthn connector 和 2FA 设置页,没有现代账户 passkey 登录主流程。账户 passkey 登录应以 `bitwarden-clients` 为准。 - -### 登录按钮可见性 - -`libs/auth/src/angular/login/default-login-component.service.ts` - -- 默认只对 `ClientType.Web` 开启 passkey 登录。 - -`apps/browser/src/auth/popup/login/extension-login-component.service.ts` - -- browser extension 覆盖逻辑:只对 Chromium 开启。 -- 注释说明 Firefox 和 Safari 不能在扩展里覆盖 relying party ID。 -- 官方代码引用了 W3C webextensions issue 238、Mozilla bug 1956484、Apple forum thread 774351。 - -结论:NodeWarden 后端即使完全兼容官方 passkey API,官方扩展也只有 Chromium 系会显示 passkey 登录入口。 - -### Passkey 登录页 - -`libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts` - -流程: - -1. 进入 `/login-with-passkey` 后自动开始认证。 -2. 调 `webAuthnLoginService.getCredentialAssertionOptions()`。 -3. 调 `webAuthnLoginService.assertCredential(options)` 触发 `navigator.credentials.get()`。 -4. 调 `webAuthnLoginService.logIn(assertion)` 走 identity token grant。 -5. 如果 `authResult.requiresTwoFactor` 为 true,显示“客户端不支持 passkey 2FA”错误。 -6. 只有本地 `keyService.userKey$(authResult.userId)` 已经拿到 user key,才运行 login success handler。 -7. 成功路由: - - Web:`/vault` - - Browser:`/tabs/vault` - - Desktop:`/vault` - -Browser popout 下还会在成功后重新打开普通 popup 并关闭 popout。 - -### 客户端 passkey 登录请求 - -`libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts` - -- GET `${identityUrl}/accounts/webauthn/assertion-options` -- 如果 NodeWarden 的 identityUrl 是站点 origin + `/identity`,实际路径就是 `/identity/accounts/webauthn/assertion-options`。 - -`libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts` - -- `navigator.credentials.get({ publicKey: options })` -- 会主动加 PRF extension: - - salt 是 `SHA-256("passwordless-login")` - - extension shape 是 `extensions.prf.eval.first` -- 从 `credential.getClientExtensionResults().prf.results.first` 取 PRF 输出。 -- 用 `WebAuthnLoginPrfKeyService.createSymmetricKeyFromPrf()` 转成 PRF key。 -- 构造 `WebAuthnLoginAssertionResponseRequest`。 -- 明确检查 `deviceResponse.extensions` 里不能含 `prf`,避免把 PRF 输出泄漏给服务端。 - -`libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts` - -- salt 常量:`passwordless-login` -- 先 SHA-256。 -- 再用 HKDF expand 拆成 64 字节: - - `"enc"` 32 bytes - - `"mac"` 32 bytes - -`libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts` - -form encoded token 请求字段: - -- `grant_type=webauthn` -- `token=` -- `deviceResponse=` -- 还会带 common device request 字段。 - -`libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts` - -`deviceResponse` shape: - -- `id` -- `rawId` -- `type` -- `extensions: {}` -- `response.authenticatorData` -- `response.signature` -- `response.clientDataJSON` -- `response.userHandle` - -全部二进制字段使用 base64url。 - -### 客户端如何用 PRF 解 vault key - -`libs/auth/src/common/login-strategies/webauthn-login.strategy.ts` - -- `setMasterKey()` 是空实现,因为 passkey 登录没有主密码 masterKey。 -- `setUserKey()`: - - 如果 token response 有 `key`,保存为 master-key-encrypted user key,兼容主密码解锁。 - - 如果 `userDecryptionOptions.webAuthnPrfOption` 存在,且本地 assertion 得到了 `prfKey`: - 1. 用 PRF key unwrap `encryptedPrivateKey`。 - 2. 用 private key decapsulate `encryptedUserKey`。 - 3. 得到 user key,写入 `keyService`。 - -核心约束:服务端永远看不到 PRF 输出。服务端只保存和返回被 PRF 相关密钥加密后的 keyset。 - -### 官方 web 设置页注册 passkey - -`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts` - -调用的 API: - -- `POST /webauthn/attestation-options` -- `POST /webauthn/assertion-options` -- `POST /webauthn` -- `GET /webauthn` -- `POST /webauthn/{id}/delete` -- `PUT /webauthn` - -`apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts` - -创建流程: - -1. 用户做 secret verification。 -2. 请求 attestation options。 -3. `navigator.credentials.create({ publicKey: options })`,并带 `extensions.prf = {}`。 -4. 从 client extension results 判断 `supportsPrf`。 -5. 如果要用于 vault encryption,再立即做一次 `navigator.credentials.get()`: - - `allowCredentials` 锁定刚创建的 credential。 - - 使用同一个 challenge、rpId、timeout、userVerification。 - - 带 PRF eval salt。 -6. 用 PRF key 和当前 user key 创建 rotateable keyset。 -7. 保存 credential,带上 `encryptedUserKey`、`encryptedPublicKey`、`encryptedPrivateKey`。 - -删除流程需要 secret verification。启用 encryption 的流程是对已有 credential 做 assertion,再创建并 PUT keyset。 - -`apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts` - -- `Enabled = 0` -- `Supported = 1` -- `Unsupported = 2` - -## NodeWarden 应实现的协议形状 - -### 公开登录流程 - -目标兼容官方客户端和 NodeWarden 自己 web: - -1. `GET /identity/accounts/webauthn/assertion-options` - - 生成 discoverable credential assertion options。 - - `allowCredentials: []` - - `userVerification: "required"` - - 返回 `{ options, token }`。 - - token 绑定 challenge、scope=`Authentication`、RP ID、origin/audience、过期时间。 - -2. Browser/web 调 `navigator.credentials.get()`。 - - NodeWarden 自己 web 也要使用 PRF extension。 - - PRF salt 必须和官方一致:`SHA-256("passwordless-login")`。 - -3. `POST /identity/connect/token` - - 支持 `grant_type=webauthn`。 - - 接收 `token`、`deviceResponse`、device fields。 - - 解 token,校验 challenge/scope/过期。 - - 验证 assertion。 - - 从 `userHandle` 找到 user id。 - - 从 credential id 找到 passkey record。 - - 更新 counter。 - - 记录/更新 device。 - - 返回 access/refresh token、`AccountKeys`、`UserDecryptionOptions.WebAuthnPrfOption`。 - -如果用户启用了 TOTP,建议为了官方兼容先遵循 Bitwarden:passkey 的 user verification 视作已满足第二因素。否则官方 passkey 登录页会进入 unsupported 2FA 错误状态。 - -### 账户 passkey 管理流程 - -建议对齐官方 API,同时在 NodeWarden 内部可挂到 `/api/webauthn`: - -- `GET /api/webauthn` -- `POST /api/webauthn/attestation-options` -- `POST /api/webauthn/assertion-options` -- `POST /api/webauthn` -- `PUT /api/webauthn` -- `POST /api/webauthn/:id/delete` - -为了官方客户端兼容,可能还需要接受无 `/api` 前缀的 aliases: - -- `/webauthn` -- `/webauthn/attestation-options` -- `/webauthn/assertion-options` -- `/webauthn/:id/delete` - -NodeWarden 自己 web 可以直接用 `/api/webauthn`,官方 web/browser 客户端会按它自己的 API base 组装 `/webauthn`。 - -### 建议新增表 - -按 NodeWarden 命名风格,建议用小写 snake_case: - -```sql -CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - public_key TEXT NOT NULL, - credential_id TEXT NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - type TEXT, - aa_guid TEXT, - transports TEXT, - encrypted_user_key TEXT, - encrypted_public_key TEXT, - encrypted_private_key TEXT, - supports_prf INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_user_credential - ON webauthn_credentials(user_id, credential_id); - -CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user - ON webauthn_credentials(user_id); -``` - -如果要更严格防止同一个 credential id 被跨用户重复注册,也可以加全局 unique index `credential_id`。官方代码至少检查同用户唯一;实际安全上更建议全局唯一,因为 credential id 本身应该唯一标识 authenticator credential。 - -PRF status 不必落库为枚举,可以由字段计算: - -- `supports_prf = 0` => `Unsupported` -- `supports_prf = 1` 且三段 encrypted key 不全 => `Supported` -- `supports_prf = 1` 且三段 encrypted key 全存在 => `Enabled` - -### Challenge/token 存储 - -官方 server 用 protected token 携带 options,再用 challenge cache 防重放。NodeWarden 在 Workers/D1 里建议组合: - -- token:HMAC/JWT 样式,绑定 `scope`、`challenge`、`userId?`、`rpId`、`createdAt`、`expiresAt`。 -- D1 表或 KV:记录 challenge 是否使用过,至少字段 `challenge_hash`、`scope`、`user_id`、`expires_at`、`used_at`。 -- 登录 assertion options 是公开接口,不绑定 user id;create/update/delete 管理流程应绑定 user id。 -- 验证成功后立即 mark used。 - -建议 scopes: - -- `Authentication` -- `CreateCredential` -- `UpdateKeySet` - -官方还有 `PrfRegistration` 语义,NodeWarden 可以用 `CreateCredential` 覆盖,只要 token 逻辑严谨即可。 - -### 服务端 WebAuthn 验证库 - -NodeWarden 当前没有 FIDO2/WebAuthn 服务端验证依赖。不要手写签名和 attestation 解析。 - -候选:`@simplewebauthn/server`。官方文档当前说明它提供 `generateRegistrationOptions`、`verifyRegistrationResponse`、`generateAuthenticationOptions`、`verifyAuthenticationResponse`,并记录了 RP ID、origin、credential public key、counter、transports 等数据结构。文档地址:https://simplewebauthn.dev/docs/packages/server - -注意:NodeWarden 跑在 Cloudflare Workers,不是普通 Node server。正式选库前需要做一次构建/runtime 验证,确认包不会依赖 Workers 不支持的 Node API。这个验证属于实现阶段,不在本研究文档里写测试程序。 - -## NodeWarden web 需要改的地方 - -### 登录页 - -当前登录 UI 在 `webapp/src/components/AuthViews.tsx`,状态和行为主要由 `webapp/src/App.tsx`、`webapp/src/lib/app-auth.ts` 管。 - -新增: - -- 登录页增加“使用 passkey 登录”按钮。 -- 新增 `performPasskeyLogin()`: - 1. GET `/identity/accounts/webauthn/assertion-options` - 2. 转换 server options 里的 base64url challenge/user id/credential id 为 ArrayBuffer。 - 3. `navigator.credentials.get()`,带 PRF salt。 - 4. POST `/identity/connect/token`,`grant_type=webauthn`。 - 5. 从 response 的 `UserDecryptionOptions.WebAuthnPrfOption` 取 encrypted keyset。 - 6. 用本地 PRF key 解出 user key。 - 7. 构造 `SessionState` 并进入 app。 - -不能复用 `completeLogin(token, email, masterKey, fallbackKdfIterations)`,因为它要求 masterKey。应新增 passkey 专用 complete 函数。 - -### 设置页 - -当前账户/安全相关 UI 在 `webapp/src/components/SettingsPage.tsx` 一带。 - -新增: - -- Passkey 列表。 -- 新建 passkey dialog。 -- 删除 passkey。 -- 对支持 PRF 但未启用 encryption 的 passkey,提供“启用用于登录解锁”的操作。 - -自己 web 的新建流程要和官方一致: - -1. 已登录状态下先验证主密码或现有 session secret。 -2. 请求 attestation options。 -3. `navigator.credentials.create()` 带 `extensions.prf = {}`。 -4. 如果用户希望这个 passkey 可直接解锁 vault,再对刚创建 credential 做一次 `navigator.credentials.get()` 获取 PRF 输出。 -5. 用 PRF key 加密/封装当前 user key,发送到 server 保存。 - -### 客户端加密能力 - -NodeWarden web 当前已经有: - -- PBKDF2 -- HKDF expand -- Bitwarden EncString 加解密 -- RSA-OAEP private key 加密 - -但 passkey PRF keyset 需要和官方策略对齐: - -- PRF key 是 64 字节 symmetric key,前 32 enc、后 32 mac。 -- `encryptedPrivateKey` 用 PRF key wrap 一个 decapsulation private key。 -- `encryptedUserKey` 用对应 public key encapsulate user key。 -- `encryptedPublicKey` 用于 key rotation。 - -这里需要认真复用或补齐 NodeWarden 现有 crypto helper,避免做出和官方客户端无法互解的 keyset。 - -## 扩展兼容要求 - -### 官方 browser extension - -官方 extension passkey 登录入口在: - -- `apps/browser/src/auth/popup/login/extension-login-component.service.ts` -- 只在 Chromium 开启。 - -如果要官方/派生扩展能对 NodeWarden passkey 登录: - -- identity URL 必须能访问 `/accounts/webauthn/assertion-options`。 -- token URL 必须支持 `grant_type=webauthn`。 -- API URL 必须能访问 `/webauthn` 管理接口。 -- response 大小写和字段名要同时照顾 PascalCase/camelCase,NodeWarden 当前 token response 已经在一些字段上双写,这个风格应继续沿用。 -- passkey 登录成功时必须返回可解开 vault 的 `webAuthnPrfOption`,否则官方组件虽然认证成功,也不会进入可用 vault。 - -### RP ID 和 origin - -自己的 web: - -- RP ID 通常是站点 host,例如 `vault.example.com`。 -- origin 是 `https://vault.example.com`。 - -官方 browser extension: - -- 扩展页面 origin 是 `chrome-extension://...`。 -- 官方之所以只开 Chromium,是因为 Chromium extension 具备它需要的 RP ID 覆盖能力。 -- NodeWarden server 验证 assertion 时必须允许正确的 origin/RP ID 组合。这里不能简单只接受当前 request origin,否则扩展登录会失败。 - -建议配置化: - -- `WEBAUTHN_RP_ID` -- `WEBAUTHN_RP_NAME` -- `WEBAUTHN_ALLOWED_ORIGINS` - -默认可以从 request URL 推导 web origin,但生产建议显式配置。 - -## 安全约束 - -- 所有账户 passkey 必须 `userVerification: required`。 -- 登录 assertion 使用 discoverable credential,`userHandle` 必须能解析成 user id 并和 credential 记录一致。 -- challenge 必须有过期时间和一次性使用标记。 -- PRF 输出绝不能传给 server,也不能写入日志。 -- token 里要绑定 scope,防止 attestation token 被拿去 authentication 用。 -- counter 要更新。遇到 counter 异常时至少记录 audit event,是否阻断要结合 multi-device passkey 现实处理。 -- 每用户 credential 数量限制建议沿用官方 5 个。 -- 删除/新增/启用 encryption 必须要求已登录用户二次验证。 -- 密码变更、user key rotation 后,所有 enabled PRF credentials 的 keyset 也要 rotation,否则 passkey 登录会解不开新 vault key。 -- 备份导出/导入必须包含账户 passkey 表,否则恢复后 passkey 登录会全部失效。 -- 审计日志建议新增: - - `auth.passkey.login.success` - - `auth.passkey.login.failed` - - `account.passkey.create` - - `account.passkey.delete` - - `account.passkey.encryption.enable` - - `account.passkey.rotate` - -## 建议实施顺序 - -### 第一阶段:后端基础 - -1. 新增 `webauthn_credentials` 和 challenge 表。 -2. 新增 storage repo。 -3. 接入 WebAuthn 服务端验证库。 -4. 实现 assertion options 和 `grant_type=webauthn`。 -5. token response 加 `WebAuthnPrfOption` shape。 - -这阶段先能让“已有手工塞入的 enabled credential”完成登录验证,但还不做 UI。 - -### 第二阶段:账户 passkey 管理 API - -1. 实现 `/api/webauthn` 和 `/webauthn` aliases。 -2. 实现 attestation options、save credential、list、delete、enable/update encryption。 -3. 加 audit event。 -4. 接入 backup export/import。 -5. sync response 加 `WebAuthnPrfOptions`。 - -### 第三阶段:NodeWarden 自己 web - -1. 登录页 passkey 按钮和 `performPasskeyLogin()`。 -2. Passkey 设置页。 -3. PRF keyset 创建、保存、删除、启用 encryption。 -4. 浏览器能力判断和错误提示。 - -### 第四阶段:扩展兼容 - -1. 用官方 browser extension 的 Chromium passkey 登录流程校对 endpoint。 -2. 校对 `/config` 里 identity/api/web vault URL。 -3. 校对 RP ID、allowed origins。 -4. 必要时加兼容字段或 alias route。 - -按用户要求,本阶段只需要代码跑通不报错;不在这里写可视化测试或测试程序。 - -## 待实现清单 - -- [ ] 设计并落库 `webauthn_credentials`。 -- [ ] 设计并落库 WebAuthn challenge/replay cache。 -- [ ] 选定并验证 Workers 可用的 WebAuthn server library。 -- [ ] `GET /identity/accounts/webauthn/assertion-options`。 -- [ ] `POST /identity/connect/token` 支持 `grant_type=webauthn`。 -- [ ] `UserDecryptionOptions.WebAuthnPrfOption`。 -- [ ] `UserDecryption.WebAuthnPrfOptions`。 -- [ ] `/api/webauthn` 管理接口。 -- [ ] `/webauthn` 官方客户端 alias。 -- [ ] NodeWarden web passkey 登录入口。 -- [ ] NodeWarden web passkey 管理页。 -- [ ] key rotation 时同步 rotate PRF keysets。 -- [ ] backup export/import 覆盖新表。 -- [ ] audit logs 覆盖 passkey 管理和登录。 - -## 关键文件索引 - -NodeWarden: - -- `src/router-public.ts` -- `src/router-authenticated.ts` -- `src/handlers/accounts.ts` -- `src/handlers/identity.ts` -- `src/handlers/sync.ts` -- `src/services/auth.ts` -- `src/services/storage-schema.ts` -- `src/services/storage-user-repo.ts` -- `src/services/storage-device-repo.ts` -- `src/utils/passkey.ts` -- `src/utils/user-decryption.ts` -- `src/types/index.ts` -- `webapp/src/lib/api/auth.ts` -- `webapp/src/lib/app-auth.ts` -- `webapp/src/components/AuthViews.tsx` -- `webapp/src/components/SettingsPage.tsx` - -Bitwarden server: - -- `.codex-upstream/bitwarden-server/src/Identity/Controllers/AccountsController.cs` -- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs` -- `.codex-upstream/bitwarden-server/src/Identity/IdentityServer/ApiClient.cs` -- `.codex-upstream/bitwarden-server/src/Api/Auth/Controllers/WebAuthnController.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/Entities/WebAuthnCredential.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialCreateOptionsCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/GetWebAuthnLoginCredentialAssertionOptionsCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/CreateWebAuthnLoginCredentialCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/UserFeatures/WebAuthnLogin/Implementations/AssertWebAuthnLoginCredentialCommand.cs` -- `.codex-upstream/bitwarden-server/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs` -- `.codex-upstream/bitwarden-server/util/SqliteMigrations/Migrations/20231213032045_WebAuthnLoginCredentials.cs` - -Bitwarden clients/browser: - -- `.codex-upstream/bitwarden-clients/libs/auth/src/angular/login/default-login-component.service.ts` -- `.codex-upstream/bitwarden-clients/apps/browser/src/auth/popup/login/extension-login-component.service.ts` -- `.codex-upstream/bitwarden-clients/libs/angular/src/auth/login-via-webauthn/login-via-webauthn.component.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-key.service.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts` -- `.codex-upstream/bitwarden-clients/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/save-credential.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/enable-credential-encryption.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/services/webauthn-login/request/webauthn-login-attestation-response.request.ts` -- `.codex-upstream/bitwarden-clients/apps/web/src/app/auth/core/enums/webauthn-login-credential-prf-status.enum.ts` -- `.codex-upstream/bitwarden-clients/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts` -- `.codex-upstream/bitwarden-clients/libs/auth/src/common/models/domain/user-decryption-options.ts` - From add921b3b336baf5dd95b647acd147e8b50da84e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 15:02:41 +0800 Subject: [PATCH 002/157] Improve Bitwarden compatibility across account, sync, attachment, and send flows --- src/handlers/accounts.ts | 135 +++++++++++++++++++--------- src/handlers/attachments.ts | 16 +++- src/handlers/identity.ts | 34 +++---- src/handlers/sends-public.ts | 13 +++ src/handlers/sync.ts | 30 +------ src/router-authenticated.ts | 7 +- src/services/storage-cipher-repo.ts | 11 ++- src/types/index.ts | 16 +++- src/utils/profile-response.ts | 36 ++++++++ src/utils/user-decryption.ts | 1 + webapp/src/lib/api/auth.ts | 30 +++++-- webapp/src/lib/api/vault.ts | 22 +++-- 12 files changed, 249 insertions(+), 102 deletions(-) create mode 100644 src/utils/profile-response.ts diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index e5a5fdc..953a1fb 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -1,4 +1,4 @@ -import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; +import { Env, User, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; @@ -9,6 +9,7 @@ import { LIMITS } from '../config/limits'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; +import { buildProfileResponse } from '../utils/profile-response'; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000; @@ -174,6 +175,24 @@ function readBodyString(body: Record, names: string[]): string return ''; } +function readNestedString(source: unknown, path: string[]): string { + let current = source; + for (const key of path) { + if (!current || typeof current !== 'object') return ''; + current = (current as Record)[key]; + } + return typeof current === 'string' ? current : ''; +} + +function readNestedNumber(source: unknown, path: string[]): number | undefined { + let current = source; + for (const key of path) { + if (!current || typeof current !== 'object') return undefined; + current = (current as Record)[key]; + } + return typeof current === 'number' ? current : undefined; +} + async function readRequestBody(request: Request): Promise> { const contentType = request.headers.get('content-type') || ''; if (contentType.includes('application/x-www-form-urlencoded')) { @@ -183,34 +202,32 @@ async function readRequestBody(request: Request): Promise { + return { + minComplexity: 0, + minLength: 0, + requireUpper: false, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + enforceOnLogin: false, + object: 'masterPasswordPolicy', + }; +} + +function keysResponse(user: User): Record { const accountKeys = buildAccountKeys(user); return { - id: user.id, - name: user.name, - email: user.email, - emailVerified: true, - premium: true, - premiumFromOrganization: false, - usesKeyConnector: false, - masterPasswordHint: user.masterPasswordHint, - culture: 'en-US', - twoFactorEnabled: !!user.totpSecret, + Key: user.key, + PublicKey: user.publicKey ?? '', + PrivateKey: user.privateKey ?? '', + AccountKeys: accountKeys, + Object: 'keys', key: user.key, - privateKey: user.privateKey, + publicKey: user.publicKey ?? '', + privateKey: user.privateKey ?? '', accountKeys, - securityStamp: user.securityStamp || user.id, - organizations: [], - providers: [], - providerOrganizations: [], - forcePasswordReset: false, - avatarColor: null, - creationDate: user.createdAt, - verifyDevices: user.verifyDevices, - role: user.role, - status: user.status, - object: 'profile', + object: 'keys', }; } @@ -445,7 +462,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); - return jsonResponse(toProfile(user, env)); + return jsonResponse(buildProfileResponse(user, env)); } // PUT /api/accounts/profile @@ -484,7 +501,7 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st }, }); - return jsonResponse(toProfile(user, env)); + return jsonResponse(buildProfileResponse(user, env)); } // PUT/POST /api/accounts/verify-devices @@ -498,6 +515,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: secret?: string; masterPasswordHash?: string; verifyDevices?: boolean; + VerifyDevices?: boolean; }; try { body = await request.json(); @@ -505,7 +523,8 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: return errorResponse('Invalid JSON', 400); } - if (typeof body.verifyDevices !== 'boolean') { + const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices; + if (typeof verifyDevices !== 'boolean') { return errorResponse('verifyDevices must be true or false', 400); } @@ -514,7 +533,7 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: return errorResponse('User verification failed.', 400); } - user.verifyDevices = body.verifyDevices; + user.verifyDevices = verifyDevices; user.updatedAt = new Date().toISOString(); await storage.saveUser(user); await writeAuditEvent(storage, { @@ -533,6 +552,19 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId: return new Response(null, { status: 200 }); } +// GET /api/accounts/keys +export async function handleGetKeys(request: Request, env: Env, userId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const user = await storage.getUserById(userId); + + if (!user) { + return errorResponse('User not found', 404); + } + + return jsonResponse(keysResponse(user)); +} + // POST /api/accounts/keys export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); @@ -593,7 +625,7 @@ export async function handleSetKeys(request: Request, env: Env, userId: string): }, }); - return handleGetProfile(request, env, userId); + return jsonResponse(keysResponse(user)); } // POST/PUT /api/accounts/password @@ -607,6 +639,7 @@ export async function handleChangePassword(request: Request, env: Env, userId: s masterPasswordHash?: string; currentPasswordHash?: string; newMasterPasswordHash?: string; + masterPasswordHint?: string | null; key?: string; newKey?: string; encryptedPrivateKey?: string; @@ -617,6 +650,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s kdfIterations?: number; kdfMemory?: number; kdfParallelism?: number; + authenticationData?: Record; + unlockData?: Record; }; try { body = await request.json(); @@ -629,10 +664,16 @@ export async function handleChangePassword(request: Request, env: Env, userId: s const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); if (!valid) return errorResponse('Invalid password', 400); - if (!body.newMasterPasswordHash) { + const newMasterPasswordHash = + body.newMasterPasswordHash || + readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']); + if (!newMasterPasswordHash) { return errorResponse('newMasterPasswordHash is required', 400); } - const nextKey = body.newKey || body.key; + const nextKey = + body.newKey || + body.key || + readNestedString(body, ['unlockData', 'masterKeyWrappedUserKey']); const nextPrivateKey = body.newEncryptedPrivateKey || body.encryptedPrivateKey; const nextPublicKey = body.newPublicKey || body.publicKey; if (nextKey && !looksLikeEncString(nextKey)) { @@ -642,17 +683,24 @@ export async function handleChangePassword(request: Request, env: Env, userId: s return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400); } - const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism); + const nextKdf = body.kdf ?? readNestedNumber(body, ['unlockData', 'kdf', 'kdfType']) ?? user.kdfType; + const nextKdfIterations = body.kdfIterations ?? readNestedNumber(body, ['unlockData', 'kdf', 'iterations']); + const nextKdfMemory = body.kdfMemory ?? readNestedNumber(body, ['unlockData', 'kdf', 'memory']); + const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']); + const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism); if (kdfErr) return errorResponse(kdfErr, 400); - user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email); + user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); if (nextKey) user.key = nextKey; if (nextPrivateKey) user.privateKey = nextPrivateKey; if (nextPublicKey) user.publicKey = nextPublicKey; - if (typeof body.kdf === 'number') user.kdfType = body.kdf; - if (typeof body.kdfIterations === 'number') user.kdfIterations = body.kdfIterations; - if (typeof body.kdfMemory === 'number') user.kdfMemory = body.kdfMemory; - if (typeof body.kdfParallelism === 'number') user.kdfParallelism = body.kdfParallelism; + if (typeof nextKdf === 'number') user.kdfType = nextKdf; + if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations; + if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory; + if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism; + if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) { + user.masterPasswordHint = body.masterPasswordHint; + } user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); await storage.saveUser(user); @@ -1061,23 +1109,26 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s return errorResponse('User not found', 404); } - let body: { masterPasswordHash?: string }; + let body: { masterPasswordHash?: string; authenticationData?: Record }; try { body = await request.json(); } catch { return errorResponse('Invalid JSON', 400); } - if (!body.masterPasswordHash) { + const masterPasswordHash = + body.masterPasswordHash || + readNestedString(body, ['authenticationData', 'masterPasswordAuthenticationHash']); + if (!masterPasswordHash) { return errorResponse('masterPasswordHash is required', 400); } - const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash, user.email); + const valid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email); if (!valid) { return errorResponse('Invalid password', 400); } - return new Response(null, { status: 200 }); + return jsonResponse(masterPasswordPolicyResponse()); } // POST /api/accounts/api-key diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index c1dea72..854abaa 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -31,6 +31,14 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +function contentDispositionAttachment(fileName: string | null | undefined): string { + const fallback = 'attachment'; + const value = String(fileName || fallback) + .replace(/[\r\n"]/g, '_') + .trim() || fallback; + return `attachment; filename="${value}"`; +} + async function writeAttachmentAudit( storage: StorageService, request: Request, @@ -415,7 +423,9 @@ export async function handlePublicDownloadAttachment( headers: { 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), + 'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Cache-Control': 'private, no-cache', + 'X-Content-Type-Options': 'nosniff', }, }); } @@ -463,9 +473,13 @@ export async function handleDeleteAttachment( // Get updated cipher for response const updatedCipher = await storage.getCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId); + const cipherResponse = cipherToResponse(updatedCipher!, attachments); return jsonResponse({ - cipher: cipherToResponse(updatedCipher!, attachments), + Cipher: cipherResponse, + cipher: cipherResponse, + Object: 'deleteAttachment', + object: 'deleteAttachment', }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index f801883..0701c2e 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -140,6 +140,20 @@ function buildPreloginResponse( }; } +function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] { + return { + minComplexity: 0, + minLength: 0, + requireUpper: false, + requireLower: false, + requireNumbers: false, + requireSpecial: false, + enforceOnLogin: false, + Object: 'masterPasswordPolicy', + object: 'masterPasswordPolicy', + }; +} + function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { // Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only. // Clients expose recovery-code entry points themselves; Android 2026.4 fails to @@ -151,9 +165,7 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.'): Re TwoFactorProviders: providers, TwoFactorProviders2: providers2, SsoEmail2faSessionToken: null, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), }; // Bitwarden clients rely on these fields to trigger the 2FA UI flow. @@ -446,9 +458,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -566,9 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -696,9 +704,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, @@ -836,9 +842,7 @@ export async function handleToken(request: Request, env: Env): Promise KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, - MasterPasswordPolicy: { - Object: 'masterPasswordPolicy', - }, + MasterPasswordPolicy: masterPasswordPolicyResponse(), ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index 064fbce..c710a02 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -33,6 +33,14 @@ import { verifySendPasswordHashB64, } from './sends-shared'; +function contentDispositionAttachment(fileName: string | null | undefined): string { + const fallback = 'send-file'; + const value = String(fileName || fallback) + .replace(/[\r\n"]/g, '_') + .trim() || fallback; + return `attachment; filename="${value}"`; +} + export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise { const storage = new StorageService(env.DB); const sendId = fromAccessId(accessId); @@ -282,6 +290,9 @@ export async function handleDownloadSendFile( if (!object) { return errorResponse('Send file not found', 404); } + const send = await storage.getSend(sendId); + const data = send ? parseStoredSendData(send) : {}; + const fileName = typeof data.fileName === 'string' ? data.fileName : fileId; const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); if (!firstUse) { @@ -292,7 +303,9 @@ export async function handleDownloadSendFile( headers: { 'Content-Type': object.contentType || 'application/octet-stream', 'Content-Length': String(object.size), + 'Content-Disposition': contentDispositionAttachment(fileName), 'Cache-Control': 'private, no-cache', + 'X-Content-Type-Options': 'nosniff', }, }); } diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 86cd097..1ea0125 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -5,12 +5,12 @@ import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepaira import { sendToResponse } from './sends'; import { LIMITS } from '../config/limits'; import { - buildAccountKeys, buildUserDecryptionCompat, buildUserDecryptionOptions, } from '../utils/user-decryption'; import { buildDomainsResponse } from '../services/domain-rules'; import { buildWebAuthnPrfOption } from '../utils/account-passkeys'; +import { buildProfileResponse } from '../utils/profile-response'; // CONTRACT: // /api/sync reuses cipherToResponse() as the single cipher response shaper. @@ -84,36 +84,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr storage.getAttachmentsByUserId(userId), excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), ]); - const accountKeys = buildAccountKeys(user); const webAuthnPrfOptions = accountPasskeys .map(buildWebAuthnPrfOption) .filter((option): option is NonNullable => !!option); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); - const profile: ProfileResponse = { - id: user.id, - name: user.name, - email: user.email, - emailVerified: true, - premium: true, - premiumFromOrganization: false, - usesKeyConnector: false, - masterPasswordHint: user.masterPasswordHint, - culture: 'en-US', - twoFactorEnabled: !!user.totpSecret, - key: user.key, - privateKey: user.privateKey, - accountKeys, - securityStamp: user.securityStamp || user.id, - organizations: [], - providers: [], - providerOrganizations: [], - forcePasswordReset: false, - avatarColor: null, - creationDate: user.createdAt, - verifyDevices: user.verifyDevices, - object: 'profile', - }; + const profile: ProfileResponse = buildProfileResponse(user, env); const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { @@ -149,6 +125,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr { omitExcludedGlobals: true } ), policies: [], + policiesNew: [], sends: sendResponses, UserDecryption: { MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, @@ -156,6 +133,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr KeyConnectorOption: null, WebAuthnPrfOption: webAuthnPrfOptions[0] || null, WebAuthnPrfOptions: webAuthnPrfOptions, + V2UpgradeToken: null, Object: 'userDecryption', }, UserDecryptionOptions: userDecryptionOptions, diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 46d091d..b0efa0b 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -3,6 +3,7 @@ import { errorResponse, jsonResponse } from './utils/response'; import { handleGetProfile, handleUpdateProfile, + handleGetKeys, handleSetKeys, handleGetRevisionDate, handleVerifyPassword, @@ -115,8 +116,10 @@ export async function handleAuthenticatedRoute( return handleChangePassword(request, env, userId); } - if (path === '/api/accounts/keys' && method === 'POST') { - return handleSetKeys(request, env, userId); + if (path === '/api/accounts/keys') { + if (method === 'GET') return handleGetKeys(request, env, userId); + if (method === 'POST') return handleSetKeys(request, env, userId); + return errorResponse('Method not allowed', 405); } if (path === '/api/accounts/totp') { diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts index a9a6d80..e118cbe 100644 --- a/src/services/storage-cipher-repo.ts +++ b/src/services/storage-cipher-repo.ts @@ -87,7 +87,7 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null { createdAt: row.created_at, updatedAt: row.updated_at, archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null, - deletedAt: row.deleted_at ?? null, + deletedAt: row.deleted_at ?? parsed.deletedAt ?? parsed.deletedDate ?? null, }; } catch { console.error('Corrupted cipher data, id:', row.id); @@ -244,7 +244,9 @@ export async function getCiphersPage( limit: number, offset: number ): Promise { - const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; + const whereDeleted = includeDeleted + ? '' + : "AND deleted_at IS NULL AND json_extract(data, '$.deletedAt') IS NULL AND json_extract(data, '$.deletedDate') IS NULL"; const res = await db .prepare( `SELECT ${selectCipherColumns()} FROM ciphers @@ -341,7 +343,10 @@ export async function bulkArchiveCiphers( `UPDATE ciphers SET archived_at = ?, updated_at = ?, data = json_remove(data, '$.archivedAt', '$.archivedDate', '$.updatedAt', '$.revisionDate') - WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL` + WHERE user_id = ? AND id IN (${placeholders}) + AND deleted_at IS NULL + AND json_extract(data, '$.deletedAt') IS NULL + AND json_extract(data, '$.deletedDate') IS NULL` ) .bind(now, now, userId, ...chunk) .run(); diff --git a/src/types/index.ts b/src/types/index.ts index e0eadc4..245773b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -465,7 +465,15 @@ export interface TokenResponse { scope: string; unofficialServer: boolean; MasterPasswordPolicy?: { + minComplexity: number; + minLength: number; + requireUpper: boolean; + requireLower: boolean; + requireNumbers: boolean; + requireSpecial: boolean; + enforceOnLogin: boolean; Object: string; + object?: string; } | null; ApiUseKeyConnector?: boolean; AccountKeys?: any | null; @@ -494,12 +502,13 @@ export interface ProfileResponse { accountKeys: any | null; securityStamp: string; organizations: any[]; + organizationsNew?: any[]; providers: any[]; providerOrganizations: any[]; forcePasswordReset: boolean; avatarColor: string | null; creationDate: string; - verifyDevices?: boolean; + verifyDevices: boolean; role?: UserRole; status?: UserStatus; object: string; @@ -558,6 +567,7 @@ export interface SyncResponse { ciphers: CipherResponse[]; domains: any; policies: any[]; + policiesNew?: any[]; sends: SendResponse[]; UserDecryption?: { MasterPasswordUnlock: MasterPasswordUnlock | null; @@ -565,6 +575,10 @@ export interface SyncResponse { KeyConnectorOption?: null; WebAuthnPrfOption?: WebAuthnPrfDecryptionOption | null; WebAuthnPrfOptions?: WebAuthnPrfDecryptionOption[]; + V2UpgradeToken?: { + WrappedUserKey1: string; + WrappedUserKey2: string; + } | null; Object?: string; } | null; // PascalCase for desktop/browser clients diff --git a/src/utils/profile-response.ts b/src/utils/profile-response.ts new file mode 100644 index 0000000..3343be8 --- /dev/null +++ b/src/utils/profile-response.ts @@ -0,0 +1,36 @@ +import type { Env, ProfileResponse, User } from '../types'; +import { buildAccountKeys } from './user-decryption'; + +export function buildProfileResponse(user: User, env?: Env): ProfileResponse { + void env; + const organizations: any[] = []; + const accountKeys = buildAccountKeys(user); + + return { + id: user.id, + name: user.name, + email: user.email, + emailVerified: true, + premium: true, + premiumFromOrganization: false, + usesKeyConnector: false, + masterPasswordHint: user.masterPasswordHint, + culture: 'en-US', + twoFactorEnabled: !!user.totpSecret, + key: user.key, + privateKey: user.privateKey, + accountKeys, + securityStamp: user.securityStamp || user.id, + organizations, + organizationsNew: organizations, + providers: [], + providerOrganizations: [], + forcePasswordReset: false, + avatarColor: null, + creationDate: user.createdAt, + verifyDevices: user.verifyDevices !== false, + role: user.role, + status: user.status, + object: 'profile', + }; +} diff --git a/src/utils/user-decryption.ts b/src/utils/user-decryption.ts index 497b783..379ba50 100644 --- a/src/utils/user-decryption.ts +++ b/src/utils/user-decryption.ts @@ -16,6 +16,7 @@ export function buildAccountKeys(user: Pick): publicKeyEncryptionKeyPair: { wrappedPrivateKey: user.privateKey, publicKey, + signedPublicKey: null, Object: 'publicKeyEncryptionKeyPair', }, Object: 'privateKeys', diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 98f9e86..dda14c4 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -500,7 +500,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); const headers = new Headers(init.headers || {}); headers.set('Authorization', `Bearer ${session.accessToken}`); - headers.set('X-NodeWarden-Web', '1'); let resp = await retryableRequest(headers); if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; @@ -509,7 +508,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess if (latest?.accessToken && latest.accessToken !== session.accessToken) { const latestHeaders = new Headers(init.headers || {}); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); - latestHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(latestHeaders); if (resp.status !== 401) return resp; } @@ -535,7 +533,6 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess const retryHeaders = new Headers(init.headers || {}); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); - retryHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(retryHeaders); return resp; }; @@ -599,14 +596,35 @@ export async function changeMasterPassword( const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); + const newMasterPasswordHash = bytesToBase64(nextHash); const resp = await authedFetch('/api/accounts/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - currentPasswordHash: current.hash, - newMasterPasswordHash: bytesToBase64(nextHash), - newKey, + masterPasswordHash: current.hash, + newMasterPasswordHash, + key: newKey, + authenticationData: { + kdf: { + kdfType: 0, + iterations: current.kdfIterations, + memory: null, + parallelism: null, + }, + masterPasswordAuthenticationHash: newMasterPasswordHash, + salt: args.email.trim().toLowerCase(), + }, + unlockData: { + kdf: { + kdfType: 0, + iterations: current.kdfIterations, + memory: null, + parallelism: null, + }, + masterKeyWrappedUserKey: newKey, + salt: args.email.trim().toLowerCase(), + }, kdf: 0, kdfIterations: current.kdfIterations, }), diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index a531b13..4da0d0b 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -20,6 +20,7 @@ import { readResponseBytesWithProgress } from '../download'; import { loadVaultCoreSyncSnapshot } from './vault-sync'; type CipherLoginData = NonNullable; +const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web'; export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise { const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); @@ -933,7 +934,7 @@ export async function repairCipherUriChecksums( const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', [NODEWARDEN_WEB_REPAIR_HEADER]: '1' }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Repair URI checksum failed')); @@ -1092,9 +1093,14 @@ export async function repairCipherKeyMismatches( if (!cipher?.id || !looksLikeCipherString(cipher.key)) continue; if (!(await hasItemKeyFieldMismatch(cipher, userEnc, userMac))) continue; if (hasUnresolvedEncryptedFields(cipher)) continue; - await updateCipher(authedFetch, session, cipher, draftFromDecryptedCipher(cipher), { - preserveRevisionDate: true, - }); + await updateCipher( + authedFetch, + session, + cipher, + draftFromDecryptedCipher(cipher), + { preserveRevisionDate: true }, + { webRepair: true } + ); repaired += 1; } @@ -1229,7 +1235,8 @@ export async function updateCipher( session: SessionState, cipher: Cipher, draft: VaultDraft, - extraPayload?: Record + extraPayload?: Record, + options?: { webRepair?: boolean } ): Promise { const payload = await buildCipherPayload(session, draft, cipher); if (extraPayload) { @@ -1238,7 +1245,10 @@ export async function updateCipher( const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(options?.webRepair ? { [NODEWARDEN_WEB_REPAIR_HEADER]: '1' } : {}), + }, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error('Update item failed'); From fe0c66c561f964b2ef27e200ec61d03bd42afe52 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 15:14:42 +0800 Subject: [PATCH 003/157] Add official Bitwarden resource sync notifications --- src/durable/notifications-hub.ts | 249 ++++++++++++++++++++++++++++++- src/handlers/ciphers.ts | 75 +++++++++- src/handlers/folders.ts | 25 +++- src/handlers/sends-private.ts | 9 ++ src/handlers/sends-shared.ts | 52 ++++++- webapp/src/App.tsx | 4 +- 6 files changed, 408 insertions(+), 6 deletions(-) diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index fa62ca0..ad72f7f 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -3,11 +3,21 @@ import type { Env } from '../types'; const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; -const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; type HubProtocol = 'json' | 'messagepack'; type HubKind = 'user' | 'anonymous-auth-request'; @@ -422,6 +432,243 @@ export function notifyUserVaultSync( waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null)); } +export function notifyUserCiphersSync( + env: Env, + userId: string, + revisionDate: string, + contextId?: string | null +): void { + waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null)); +} + +export function notifyUserCipherCreate( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserCipherUpdate( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserCipherDelete( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderCreate( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderUpdate( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderDelete( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendCreate( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendUpdate( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendDelete( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + export function notifyUserLogout( env: Env, userId: string, diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 1563766..11921d3 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -11,7 +11,13 @@ import { PasswordHistory, } from '../types'; import { StorageService } from '../services/storage'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserCipherCreate, + notifyUserCipherDelete, + notifyUserCipherUpdate, + notifyUserCiphersSync, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; @@ -51,6 +57,60 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +function notifyCipherCreateForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherCreate(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + +function notifyCipherUpdateForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherUpdate(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + +function notifyCipherDeleteForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherDelete(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { if (!source || typeof source !== 'object') return { present: false, value: undefined }; for (const key of aliases) { @@ -815,6 +875,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherCreateForRequest(request, env, cipher, revisionDate); const responseOptions = cipherResponseOptionsForRequest(request); return jsonResponse( @@ -925,6 +986,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); const responseOptions = cipherResponseOptionsForRequest(request); @@ -949,6 +1011,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', { id: cipher.id, type: cipher.type, @@ -978,6 +1041,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { id, type: cipher.type, @@ -1005,6 +1069,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { id, type: cipher.type, @@ -1029,6 +1094,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); return jsonResponse( cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) @@ -1068,6 +1134,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); return jsonResponse( cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) @@ -1144,6 +1211,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( @@ -1192,6 +1260,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI const revisionDate = await storage.bulkArchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return buildCipherListResponse(request, storage, userId, ids); @@ -1216,6 +1285,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return buildCipherListResponse(request, storage, userId, ids); @@ -1239,6 +1309,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', { count: body.ids.length, }); @@ -1265,6 +1336,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return new Response(null, { status: 204 }); @@ -1301,6 +1373,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', { count: ownedIds.length, requestedCount: ids.length, diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 08ce5a5..84d87e6 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -1,5 +1,10 @@ import { Env, Folder, FolderResponse } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserFolderCreate, + notifyUserFolderDelete, + notifyUserFolderUpdate, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -111,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str await storage.saveFolder(folder); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderCreate(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); return jsonResponse(folderToResponse(folder), 200); } @@ -139,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str await storage.saveFolder(folder); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderUpdate(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); return jsonResponse(folderToResponse(folder)); } @@ -156,6 +173,12 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str await storage.deleteFolder(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderDelete(env, { + userId, + folderId: id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); await writeFolderAudit(storage, request, userId, 'folder.delete', { id, }); diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index 67daaf2..c8d0700 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -16,6 +16,9 @@ import { formatSize, getAliasedProp, normalizeEmails, + notifySendCreateForRequest, + notifySendDeleteForRequest, + notifySendUpdateForRequest, notifyVaultSyncForRequest, parseDate, parseFileLength, @@ -249,6 +252,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendCreateForRequest(request, env, send.id, userId, revisionDate); return jsonResponse(sendToResponse(send)); } @@ -372,6 +376,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId: await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendCreateForRequest(request, env, send.id, userId, revisionDate); const jwtSecret = getSafeJwtSecret(env); if (!jwtSecret) { return errorResponse('Server configuration error', 500); @@ -619,6 +624,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); return jsonResponse(sendToResponse(send)); } @@ -641,6 +647,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin await storage.deleteSend(sendId, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendDeleteForRequest(request, env, sendId, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.delete', { id: sendId, type: send.type, @@ -697,6 +704,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.password.remove', { id: send.id, type: send.type, @@ -718,6 +726,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.auth.remove', { id: send.id, type: send.type, diff --git a/src/handlers/sends-shared.ts b/src/handlers/sends-shared.ts index d5d97ff..b513422 100644 --- a/src/handlers/sends-shared.ts +++ b/src/handlers/sends-shared.ts @@ -1,5 +1,10 @@ import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserSendCreate, + notifyUserSendDelete, + notifyUserSendUpdate, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +export function notifySendCreateForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendCreate(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + +export function notifySendUpdateForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendUpdate(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + +export function notifySendDeleteForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendDelete(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { if (!source || typeof source !== 'object') return { present: false, value: undefined }; for (const key of aliases) { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index cd49fc1..53ddae6 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -136,8 +136,8 @@ const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; -const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; -const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; +const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; type ThemePreference = 'system' | 'light' | 'dark'; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30; From f096681a2b3b4204a2fa46e40c0080a6bb7e876b Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 15:38:51 +0800 Subject: [PATCH 004/157] Align public send access notifications with Bitwarden --- src/handlers/sends-public.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index c710a02..3d3b568 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -21,6 +21,7 @@ import { getSafeJwtSecret, hasEmailAuth, isSendAvailable, + notifySendUpdateForRequest, notifyVaultSyncForRequest, parseStoredSendData, resolveSendFromIdOrAccessId, @@ -98,6 +99,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str send.accessCount += 1; const revisionDate = await storage.updateRevisionDate(send.userId); notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate); } const creatorIdentifier = await getCreatorIdentifier(storage, send); @@ -171,6 +173,7 @@ export async function handleAccessSendFile( send.accessCount += 1; const revisionDate = await storage.updateRevisionDate(send.userId); notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate); const token = await createSendFileDownloadToken(send.id, fileId, secret); const url = new URL(request.url); @@ -211,6 +214,7 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise Date: Sun, 21 Jun 2026 15:42:09 +0800 Subject: [PATCH 005/157] Emit cipher update notifications for attachment changes --- src/handlers/attachments.ts | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 854abaa..890ded3 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -1,5 +1,5 @@ -import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types'; +import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; @@ -31,6 +31,30 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +function normalizeOptionalId(value: unknown): string | null { + if (value == null) return null; + const normalized = String(value).trim(); + return normalized ? normalized : null; +} + +function notifyCipherUpdateForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherUpdate(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + function contentDispositionAttachment(fileName: string | null | undefined): string { const fallback = 'attachment'; const value = String(fileName || fallback) @@ -83,6 +107,7 @@ async function runWithConcurrency( async function processAttachmentUpload( request: Request, env: Env, + cipher: Cipher, attachment: Attachment, cipherId: string ): Promise { @@ -124,6 +149,7 @@ async function processAttachmentUpload( const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate); } return new Response(null, { status: 201 }); @@ -184,6 +210,7 @@ export async function handleCreateAttachment( const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate); } // Get updated cipher for response @@ -227,7 +254,7 @@ export async function handleUploadAttachment( return errorResponse('Attachment not found', 404); } - return processAttachmentUpload(request, env, attachment, cipherId); + return processAttachmentUpload(request, env, cipher, attachment, cipherId); } export async function handlePublicUploadAttachment( @@ -265,7 +292,7 @@ export async function handlePublicUploadAttachment( return errorResponse('Attachment not found', 404); } - return processAttachmentUpload(request, env, attachment, cipherId); + return processAttachmentUpload(request, env, cipher, attachment, cipherId); } // GET /api/ciphers/{cipherId}/attachment/{attachmentId} @@ -356,6 +383,7 @@ export async function handleUpdateAttachmentMetadata( const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate); } return jsonResponse({ @@ -463,6 +491,7 @@ export async function handleDeleteAttachment( const revisionInfo = await storage.updateCipherRevisionDate(cipherId); if (revisionInfo) { notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionInfo.revisionDate); await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', { id: attachmentId, cipherId, From f9fe53285f18050d116100cd687113122e04cfcf Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 15:46:37 +0800 Subject: [PATCH 006/157] Preserve stored cipher permission flags in responses --- src/handlers/ciphers.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 11921d3..8a4bc27 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -48,6 +48,22 @@ function normalizeOptionalId(value: unknown): string | null { return normalized ? normalized : null; } +function readBooleanOrFallback(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function buildCipherPermissions(passthrough: Record): { delete: boolean; restore: boolean } { + const raw = passthrough.permissions; + const source = raw && typeof raw === 'object' && !Array.isArray(raw) + ? raw as Record + : null; + + return { + delete: readBooleanOrFallback(source?.delete, true), + restore: readBooleanOrFallback(source?.restore, true), + }; +} + function notifyVaultSyncForRequest( request: Request, env: Env, @@ -705,6 +721,7 @@ export function cipherToResponse( ? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 } : null; const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments); + const responsePermissions = buildCipherPermissions(passthrough); return { // Pass through ALL stored cipher fields (known + unknown) @@ -718,12 +735,9 @@ export function cipherToResponse( revisionDate: updatedAt, deletedDate: deletedAt, archivedDate: archivedAt ?? null, - edit: true, - viewPassword: true, - permissions: { - delete: true, - restore: true, - }, + edit: readBooleanOrFallback((passthrough as any).edit, true), + viewPassword: readBooleanOrFallback((passthrough as any).viewPassword, true), + permissions: responsePermissions, object: 'cipherDetails', collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [], attachments: formatAttachments(responseAttachments), From 42b765b113f41e8fbf0148cb212d3fc2fdf93daa Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 16:14:20 +0800 Subject: [PATCH 007/157] Use resource sync notifications in the web client --- src/handlers/folders.ts | 14 +++ src/handlers/sends-private.ts | 3 + webapp/src/App.tsx | 223 ++++++++++++++++++++++++++++++++-- webapp/src/lib/api/send.ts | 11 ++ webapp/src/lib/api/vault.ts | 23 ++++ 5 files changed, 264 insertions(+), 10 deletions(-) diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 84d87e6..c7826c2 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -202,9 +202,23 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId return errorResponse('Folder ids are required', 400); } + const folders = ( + await Promise.all(ids.map(async (id) => { + const folder = await storage.getFolder(id); + return folder && folder.userId === userId ? folder : null; + })) + ).filter((folder): folder is Folder => !!folder); const revisionDate = await storage.bulkDeleteFolders(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + for (const folder of folders) { + notifyUserFolderDelete(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); + } await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', { count: ids.length, }); diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index c8d0700..cf8c8de 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -683,6 +683,9 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId: const revisionDate = await storage.bulkDeleteSends(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + for (const send of sends) { + notifySendDeleteForRequest(request, env, send.id, userId, revisionDate); + } await writeSendAudit(storage, request, userId, 'send.delete.bulk', { count: sends.length, requestedCount: body.ids.length, diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 53ddae6..6414617 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -31,8 +31,8 @@ import { } from '@/lib/api/auth-requests'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; -import { getSends } from '@/lib/api/send'; -import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; +import { getSendById, getSends } from '@/lib/api/send'; +import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { @@ -134,8 +134,18 @@ function normalizeRoutePath(path: string): string { } const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; @@ -1327,6 +1337,169 @@ export default function App() { silentRefreshVaultRef.current = refreshVaultSilently; + function normalizeVaultCoreSnapshot(snapshot?: Partial | null): VaultCoreSnapshot { + return { + ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [], + folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [], + sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [], + }; + } + + function upsertById(items: T[], nextItem: T): T[] { + const nextId = String(nextItem.id || '').trim(); + if (!nextId) return items; + const index = items.findIndex((item) => String(item.id || '').trim() === nextId); + if (index < 0) return [...items, nextItem]; + const next = items.slice(); + next[index] = nextItem; + return next; + } + + function removeById(items: T[], id: string): T[] { + const normalizedId = String(id || '').trim(); + if (!normalizedId) return items; + return items.filter((item) => String(item.id || '').trim() !== normalizedId); + } + + function patchVaultCoreSnapshot(updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot): void { + if (!vaultCacheKey) return; + let nextSnapshot: VaultCoreSnapshot | null = null; + queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => { + const base = normalizeVaultCoreSnapshot(previous || cachedVaultCore); + nextSnapshot = updater(base); + return nextSnapshot; + }); + if (nextSnapshot) setCachedVaultCore(nextSnapshot); + } + + function upsertEncryptedCipher(cipher: Cipher): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: upsertById(snapshot.ciphers, cipher), + })); + } + + function deleteCipherLocally(cipherId: string): void { + const id = String(cipherId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: removeById(snapshot.ciphers, id), + })); + setDecryptedCiphers((current) => removeById(current, id)); + } + + function upsertEncryptedFolder(folder: VaultFolder): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: upsertById(snapshot.folders, folder), + })); + } + + function deleteFolderLocally(folderId: string): void { + const id = String(folderId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: removeById(snapshot.folders, id), + ciphers: snapshot.ciphers.map((cipher) => ( + String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher + )), + })); + setDecryptedFolders((current) => removeById(current, id)); + setDecryptedCiphers((current) => current.map((cipher) => ( + String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher + ))); + } + + function upsertEncryptedSend(send: Send): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + sends: upsertById(snapshot.sends, send), + })); + queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send)); + } + + function deleteSendLocally(sendId: string): void { + const id = String(sendId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + sends: removeById(snapshot.sends, id), + })); + queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id)); + setDecryptedSends((current) => removeById(current, id)); + } + + async function upsertCipherFromNotification(cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getCipherById(authedFetch, id); + upsertEncryptedCipher(encrypted); + const result = await decryptVaultCore({ + folders: [], + ciphers: [encrypted], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + }); + const decrypted = result.ciphers[0]; + if (decrypted) setDecryptedCiphers((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteCipherLocally(id); + return; + } + console.warn('Failed to upsert cipher from notification:', error); + } + } + + async function upsertFolderFromNotification(folderId: string): Promise { + const id = String(folderId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getFolderById(authedFetch, id); + upsertEncryptedFolder(encrypted); + const result = await decryptVaultCore({ + folders: [encrypted], + ciphers: [], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + }); + const decrypted = result.folders[0]; + if (decrypted) setDecryptedFolders((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteFolderLocally(id); + return; + } + console.warn('Failed to upsert folder from notification:', error); + } + } + + async function upsertSendFromNotification(sendId: string): Promise { + const id = String(sendId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getSendById(authedFetch, id); + upsertEncryptedSend(encrypted); + const sends = await decryptSends({ + sends: [encrypted], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + origin: window.location.origin, + }); + const decrypted = sends[0]; + if (decrypted) setDecryptedSends((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteSendLocally(id); + return; + } + console.warn('Failed to upsert send from notification:', error); + } + } + useEffect(() => { if (IS_DEMO_MODE) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return; @@ -1404,6 +1577,10 @@ export default function App() { for (const frame of frames) { if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; const updateType = Number(frame.arguments?.[0]?.Type || 0); + const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); + const payload = frame.arguments?.[0]?.Payload; + const payloadRecord = payload && typeof payload === 'object' ? payload as Record : null; + const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim(); if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { logoutNow(); return; @@ -1417,16 +1594,42 @@ export default function App() { if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } - if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; - const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; - if (notificationRefreshTimerRef.current !== null) { - window.clearTimeout(notificationRefreshTimerRef.current); + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS) { + if (notificationRefreshTimerRef.current !== null) { + window.clearTimeout(notificationRefreshTimerRef.current); + } + notificationRefreshTimerRef.current = window.setTimeout(() => { + notificationRefreshTimerRef.current = null; + void silentRefreshVaultRef.current(); + }, 250); + continue; } - notificationRefreshTimerRef.current = window.setTimeout(() => { - notificationRefreshTimerRef.current = null; - void silentRefreshVaultRef.current(); - }, 250); + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) { + void upsertCipherFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) { + deleteCipherLocally(resourceId); + continue; + } + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) { + void upsertFolderFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) { + deleteFolderLocally(resourceId); + continue; + } + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) { + void upsertSendFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) { + deleteSendLocally(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; } }); diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts index 3230827..104c986 100644 --- a/webapp/src/lib/api/send.ts +++ b/webapp/src/lib/api/send.ts @@ -67,6 +67,17 @@ export async function getSends(authedFetch: AuthedFetch): Promise { return body?.data || []; } +export async function getSendById(authedFetch: AuthedFetch, sendId: string): Promise { + const id = String(sendId || '').trim(); + if (!id) throw new Error('Send id is required'); + const resp = await authedFetch(`/api/sends/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Send not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load send failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load send failed'); + return body; +} + export async function createSend( authedFetch: AuthedFetch, session: SessionState, diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 4da0d0b..8bfbe0f 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -10,6 +10,7 @@ import type { import { BULK_API_CHUNK_SIZE, chunkArray, + createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, @@ -27,6 +28,17 @@ export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Pr return body.folders || []; } +export async function getFolderById(authedFetch: AuthedFetch, folderId: string): Promise { + const id = String(folderId || '').trim(); + if (!id) throw new Error('Folder id is required'); + const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Folder not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load folder failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load folder failed'); + return body; +} + export async function createFolder( authedFetch: AuthedFetch, session: SessionState, @@ -100,6 +112,17 @@ export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Pr return body.ciphers || []; } +export async function getCipherById(authedFetch: AuthedFetch, cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Cipher not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load cipher failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load cipher failed'); + return body; +} + export interface CiphersImportPayload { ciphers: Array>; folders: Array<{ name: string }>; From 045b23fc47195204c9230ba40b1357b824c0f2ba Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 18:16:44 +0800 Subject: [PATCH 008/157] Align web vault updates with resource sync --- src/handlers/sends-private.ts | 1 + webapp/src/App.tsx | 118 +++++++++---- webapp/src/hooks/useVaultSendActions.ts | 214 +++++++++++++++++------- webapp/src/lib/api/vault-sync.ts | 23 +++ webapp/src/lib/api/vault.ts | 11 +- 5 files changed, 274 insertions(+), 93 deletions(-) diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index cf8c8de..15ac586 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -102,6 +102,7 @@ async function processSendFileUpload( const storage = new StorageService(env.DB); const revisionDate = await storage.updateRevisionDate(send.userId); notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate); return new Response(null, { status: 201 }); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6414617..25bb9be 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -20,6 +20,7 @@ import { saveProfileSnapshot, revokeCurrentSession, getTotpStatus, + getVaultRevisionDate, saveSession, stripProfileSecrets, } from '@/lib/api/auth'; @@ -33,7 +34,7 @@ import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSendById, getSends } from '@/lib/api/send'; import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; -import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; +import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot, saveVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { parseSignalRTextFrames, @@ -1361,7 +1362,15 @@ export default function App() { return items.filter((item) => String(item.id || '').trim() !== normalizedId); } - function patchVaultCoreSnapshot(updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot): void { + function revisionStampFromIso(value: unknown): number | null { + const stamp = new Date(String(value || '').trim()).getTime(); + return Number.isFinite(stamp) && stamp > 0 ? stamp : null; + } + + function patchVaultCoreSnapshot( + updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot, + options?: { revisionStamp?: number | null } + ): void { if (!vaultCacheKey) return; let nextSnapshot: VaultCoreSnapshot | null = null; queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => { @@ -1369,34 +1378,50 @@ export default function App() { nextSnapshot = updater(base); return nextSnapshot; }); - if (nextSnapshot) setCachedVaultCore(nextSnapshot); + if (nextSnapshot) { + setCachedVaultCore(nextSnapshot); + void saveVaultCoreSyncSnapshot(vaultCacheKey, nextSnapshot, options?.revisionStamp ?? null); + } } - function upsertEncryptedCipher(cipher: Cipher): void { + async function refreshVaultCoreRevisionStamp(): Promise { + if (!vaultCacheKey || !session?.accessToken) return; + try { + const revisionStamp = await getVaultRevisionDate(authedFetch); + const currentSnapshot = normalizeVaultCoreSnapshot( + queryClient.getQueryData(['vault-core', vaultCacheKey]) || cachedVaultCore + ); + await saveVaultCoreSyncSnapshot(vaultCacheKey, currentSnapshot, revisionStamp); + } catch { + // A stale revision stamp only affects the next cache validation; the local resource patch remains valid. + } + } + + function upsertEncryptedCipher(cipher: Cipher, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, ciphers: upsertById(snapshot.ciphers, cipher), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(cipher.revisionDate) }); } - function deleteCipherLocally(cipherId: string): void { + function deleteCipherLocally(cipherId: string, revisionStamp?: number | null): void { const id = String(cipherId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, ciphers: removeById(snapshot.ciphers, id), - })); + }), { revisionStamp }); setDecryptedCiphers((current) => removeById(current, id)); } - function upsertEncryptedFolder(folder: VaultFolder): void { + function upsertEncryptedFolder(folder: VaultFolder, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, folders: upsertById(snapshot.folders, folder), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(folder.revisionDate) }); } - function deleteFolderLocally(folderId: string): void { + function deleteFolderLocally(folderId: string, revisionStamp?: number | null): void { const id = String(folderId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ @@ -1405,38 +1430,38 @@ export default function App() { ciphers: snapshot.ciphers.map((cipher) => ( String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher )), - })); + }), { revisionStamp }); setDecryptedFolders((current) => removeById(current, id)); setDecryptedCiphers((current) => current.map((cipher) => ( String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher ))); } - function upsertEncryptedSend(send: Send): void { + function upsertEncryptedSend(send: Send, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, sends: upsertById(snapshot.sends, send), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(send.revisionDate) }); queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send)); } - function deleteSendLocally(sendId: string): void { + function deleteSendLocally(sendId: string, revisionStamp?: number | null): void { const id = String(sendId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, sends: removeById(snapshot.sends, id), - })); + }), { revisionStamp }); queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id)); setDecryptedSends((current) => removeById(current, id)); } - async function upsertCipherFromNotification(cipherId: string): Promise { + async function upsertCipherFromNotification(cipherId: string, revisionStamp?: number | null): Promise { const id = String(cipherId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getCipherById(authedFetch, id); - upsertEncryptedCipher(encrypted); + upsertEncryptedCipher(encrypted, revisionStamp); const result = await decryptVaultCore({ folders: [], ciphers: [encrypted], @@ -1454,12 +1479,12 @@ export default function App() { } } - async function upsertFolderFromNotification(folderId: string): Promise { + async function upsertFolderFromNotification(folderId: string, revisionStamp?: number | null): Promise { const id = String(folderId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getFolderById(authedFetch, id); - upsertEncryptedFolder(encrypted); + upsertEncryptedFolder(encrypted, revisionStamp); const result = await decryptVaultCore({ folders: [encrypted], ciphers: [], @@ -1477,12 +1502,12 @@ export default function App() { } } - async function upsertSendFromNotification(sendId: string): Promise { + async function upsertSendFromNotification(sendId: string, revisionStamp?: number | null): Promise { const id = String(sendId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getSendById(authedFetch, id); - upsertEncryptedSend(encrypted); + upsertEncryptedSend(encrypted, revisionStamp); const sends = await decryptSends({ sends: [encrypted], symEncKeyB64: session.symEncKey, @@ -1576,11 +1601,18 @@ export default function App() { const frames = parseSignalRTextFrames(event.data); for (const frame of frames) { if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; - const updateType = Number(frame.arguments?.[0]?.Type || 0); - const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); - const payload = frame.arguments?.[0]?.Payload; + const message = frame.arguments?.[0] as Record | undefined; + const updateType = Number(message?.Type || 0); + const contextId = String(message?.ContextId || '').trim(); + const payload = message?.Payload; const payloadRecord = payload && typeof payload === 'object' ? payload as Record : null; const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim(); + const revisionStamp = revisionStampFromIso( + payloadRecord?.RevisionDate + || payloadRecord?.revisionDate + || message?.Date + || message?.date + ); if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { logoutNow(); return; @@ -1590,7 +1622,6 @@ export default function App() { continue; } if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { - const payload = frame.arguments?.[0]?.Payload; if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } @@ -1606,27 +1637,27 @@ export default function App() { continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) { - void upsertCipherFromNotification(resourceId); + void upsertCipherFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) { - deleteCipherLocally(resourceId); + deleteCipherLocally(resourceId, revisionStamp); continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) { - void upsertFolderFromNotification(resourceId); + void upsertFolderFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) { - deleteFolderLocally(resourceId); + deleteFolderLocally(resourceId, revisionStamp); continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) { - void upsertSendFromNotification(resourceId); + void upsertSendFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) { - deleteSendLocally(resourceId); + deleteSendLocally(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; @@ -1688,8 +1719,33 @@ export default function App() { }, refetchSends: refetchSendsFromVaultCore, onNotify: pushToast, + patchEncryptedCiphers: (updater) => { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: updater(snapshot.ciphers), + })); + }, + patchEncryptedFolders: (updater) => { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: updater(snapshot.folders), + })); + }, + patchEncryptedSends: (updater) => { + let nextSends: Send[] = []; + patchVaultCoreSnapshot((snapshot) => { + nextSends = updater(snapshot.sends); + return { + ...snapshot, + sends: nextSends, + }; + }); + queryClient.setQueryData(sendsQueryKey, nextSends); + }, patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedFolders: setDecryptedFolders, + patchDecryptedSends: setDecryptedSends, + refreshVaultRevisionStamp: refreshVaultCoreRevisionStamp, }); const accountSecurityActions = useAccountSecurityActions({ authedFetch, diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index cbe56e3..a3ae8dc 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -41,6 +41,7 @@ import { downloadCipherAttachmentDecrypted, encryptFolderImportName, getAttachmentDownloadInfo, + getCipherById, importCiphers, permanentDeleteCipher, type CiphersImportPayload, @@ -69,8 +70,13 @@ interface UseVaultSendActionsOptions { refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchSends: () => Promise; onNotify: Notify; + patchEncryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; + patchEncryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void; + patchEncryptedSends: (updater: (prev: Send[]) => Send[]) => void; patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void; + patchDecryptedSends: (updater: (prev: Send[]) => Send[]) => void; + refreshVaultRevisionStamp: () => Promise; } function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { @@ -288,8 +294,13 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) refetchFolders, refetchSends, onNotify, + patchEncryptedCiphers, + patchEncryptedFolders, + patchEncryptedSends, patchDecryptedCiphers, patchDecryptedFolders, + patchDecryptedSends, + refreshVaultRevisionStamp, } = options; const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState(null); @@ -308,21 +319,20 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) throw new Error(t('txt_offline_vault_readonly')); }; - const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => { - const tasks: Promise[] = [Promise.resolve(refetchCiphers())]; - if (options?.includeFolders) { - tasks.push(Promise.resolve(refetchFolders())); - } - void Promise.all(tasks).catch((err) => { - console.warn('Background vault sync failed:', err); - }); - }; - async function decryptAndPatch(encrypted: Cipher) { if (!session?.symEncKey || !session?.symMacKey) { await refetchCiphers(); return; } + patchEncryptedCiphers((prev) => { + const idx = prev.findIndex((c) => c.id === encrypted.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = encrypted; + return next; + } + return [encrypted, ...prev]; + }); const encKey = base64ToBytes(session.symEncKey); const macKey = base64ToBytes(session.symMacKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); @@ -342,6 +352,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await refetchCiphers(); return; } + patchEncryptedCiphers((prev) => [encrypted, ...prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== encrypted.id)]); const encKey = base64ToBytes(session.symEncKey); const macKey = base64ToBytes(session.symMacKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); @@ -352,31 +363,70 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } function removeCipherFromState(id: string) { + patchEncryptedCiphers((prev) => prev.filter((c) => c.id !== id)); patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id)); } - function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) { + function patchCipherBatch( + ids: string[], + updater: (cipher: Cipher) => Cipher | null, + options?: { patchEncrypted?: boolean; patchDecrypted?: boolean } + ) { const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); if (!idSet.size) return; - patchDecryptedCiphers((prev) => { - let changed = false; - const next: Cipher[] = []; - for (const cipher of prev) { - if (!idSet.has(cipher.id)) { - next.push(cipher); - continue; + const shouldPatchEncrypted = options?.patchEncrypted !== false; + const shouldPatchDecrypted = options?.patchDecrypted !== false; + if (shouldPatchEncrypted) { + patchEncryptedCiphers((prev) => { + let changed = false; + const next: Cipher[] = []; + for (const cipher of prev) { + if (!idSet.has(cipher.id)) { + next.push(cipher); + continue; + } + const updated = updater(cipher); + changed = true; + if (updated) next.push(updated); } - const updated = updater(cipher); - changed = true; - if (updated) next.push(updated); - } - return changed ? next : prev; - }); + return changed ? next : prev; + }); + } + if (shouldPatchDecrypted) { + patchDecryptedCiphers((prev) => { + let changed = false; + const next: Cipher[] = []; + for (const cipher of prev) { + if (!idSet.has(cipher.id)) { + next.push(cipher); + continue; + } + const updated = updater(cipher); + changed = true; + if (updated) next.push(updated); + } + return changed ? next : prev; + }); + } } function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) { const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); if (!idSet.size) return; + patchEncryptedFolders((prev) => { + let changed = false; + const next: VaultFolder[] = []; + for (const folder of prev) { + if (!idSet.has(folder.id)) { + next.push(folder); + continue; + } + const updated = updater(folder); + changed = true; + if (updated) next.push(updated); + } + return changed ? next : prev; + }); patchDecryptedFolders((prev) => { let changed = false; const next: VaultFolder[] = []; @@ -393,6 +443,31 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }); } + function upsertEncryptedFolder(folder: VaultFolder) { + patchEncryptedFolders((prev) => { + const index = prev.findIndex((item) => item.id === folder.id); + if (index < 0) return [folder, ...prev]; + const next = [...prev]; + next[index] = folder; + return next; + }); + } + + function upsertSend(send: Send) { + patchEncryptedSends((prev) => { + const index = prev.findIndex((item) => item.id === send.id); + if (index < 0) return [send, ...prev]; + const next = [...prev]; + next[index] = send; + return next; + }); + } + + function removeSend(id: string) { + patchEncryptedSends((prev) => prev.filter((send) => send.id !== id)); + patchDecryptedSends((prev) => prev.filter((send) => send.id !== id)); + } + const uploadImportedAttachments = async ( attachments: ImportAttachmentFile[], idMaps: { byIndex: Map; bySourceId: Map } @@ -468,8 +543,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); } - await decryptAndReplaceOptimistic(optimistic.id, created); - syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 }); + const finalCipher = attachments.length ? await getCipherById(authedFetch, created.id) : created; + await decryptAndReplaceOptimistic(optimistic.id, finalCipher); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_created')); } catch (error) { patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id)); @@ -511,7 +587,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) .filter((attachment) => !removedSet.has(String(attachment?.id || '').trim())) .map((attachment) => ({ ...attachment })); } - patchCipherBatch([cipher.id], () => optimistic); + patchCipherBatch([cipher.id], () => optimistic, { patchEncrypted: false }); try { const updated = await updateCipher(authedFetch, session, cipher, draft); for (const attachmentId of removeAttachmentIds) { @@ -524,16 +600,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); } - await decryptAndPatch(updated); - syncVaultCoreInBackground({ - includeFolders: - draft.folderId !== (cipher.folderId || '') - || addFiles.length > 0 - || removeAttachmentIds.length > 0, - }); + const finalCipher = addFiles.length || removeAttachmentIds.length + ? await getCipherById(authedFetch, cipher.id) + : updated; + await decryptAndPatch(finalCipher); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_updated')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); throw error; } finally { @@ -572,7 +646,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await permanentDeleteCipher(authedFetch, cipher.id); patchCipherBatch([cipher.id], () => null); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_deleted_permanently')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed')); @@ -585,10 +659,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const deleted = await deleteCipher(authedFetch, cipher.id); await decryptAndPatch(deleted); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_deleted')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); throw error; } @@ -607,10 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const archived = await archiveCipher(authedFetch, cipher.id); await decryptAndPatch(archived); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_archived')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); throw error; } @@ -629,10 +703,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const unarchived = await unarchiveCipher(authedFetch, cipher.id); await decryptAndPatch(unarchived); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_unarchived')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); throw error; } @@ -649,7 +723,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await bulkDeleteCiphers(authedFetch, ids); const deletedDate = new Date().toISOString(); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); @@ -668,7 +742,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await bulkArchiveCiphers(authedFetch, ids); const archivedDate = new Date().toISOString(); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_archived_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); @@ -686,7 +760,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkUnarchiveCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_unarchived_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); @@ -704,7 +778,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkMoveCiphers(authedFetch, ids, folderId); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_moved_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); @@ -727,15 +801,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { if (!session) throw new Error(t('txt_vault_key_unavailable')); const created = await createFolder(authedFetch, session, folderName); + upsertEncryptedFolder(created); patchDecryptedFolders((prev) => [ { id: created.id, name: created.name || folderName, decName: folderName, + revisionDate: created.revisionDate, + creationDate: created.creationDate, }, ...prev, ]); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_created')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); @@ -758,8 +835,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await deleteFolder(authedFetch, id); patchFolderBatch([id], () => null); + patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher))); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); @@ -786,9 +864,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { if (!session) throw new Error(t('txt_vault_key_unavailable')); - await updateFolder(authedFetch, session, id, nextName); - patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName })); - syncVaultCoreInBackground({ includeFolders: true }); + const updated = await updateFolder(authedFetch, session, id, nextName); + upsertEncryptedFolder(updated); + patchDecryptedFolders((prev) => prev.map((folder) => ( + folder.id === id + ? { ...folder, name: updated.name || folder.name, decName: nextName, revisionDate: updated.revisionDate } + : folder + ))); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed')); @@ -806,7 +889,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkRestoreCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_restored_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); @@ -824,7 +907,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkPermanentDeleteCiphers(authedFetch, ids); patchCipherBatch(ids, () => null); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_items_permanently')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); @@ -844,9 +927,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkDeleteFolders(authedFetch, ids); const removedIds = new Set(ids); + patchEncryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id))); + patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher))); patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher))); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folders_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); @@ -874,7 +959,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setSendUploadPercent(0); } const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined); - await refetchSends(); + upsertSend(created); + void refreshVaultRevisionStamp(); if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); @@ -900,7 +986,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { const updated = await updateSend(authedFetch, session, send, draft); - await refetchSends(); + upsertSend(updated); + void refreshVaultRevisionStamp(); if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); @@ -922,7 +1009,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { await deleteSend(authedFetch, send.id); - await refetchSends(); + removeSend(send.id); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_send_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed')); @@ -939,7 +1027,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { await bulkDeleteSends(authedFetch, ids); - await refetchSends(); + const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); + patchEncryptedSends((prev) => prev.filter((send) => !idSet.has(send.id))); + patchDecryptedSends((prev) => prev.filter((send) => !idSet.has(send.id))); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_sends')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed')); @@ -1299,10 +1390,17 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) encryptedFolders, importAuthedFetch, onNotify, + patchDecryptedCiphers, + patchDecryptedFolders, + patchDecryptedSends, + patchEncryptedCiphers, + patchEncryptedFolders, + patchEncryptedSends, profile, refetchCiphers, refetchFolders, refetchSends, + refreshVaultRevisionStamp, session, sendUploadPercent, uploadingAttachmentName, diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index 8e0fbb7..f37111b 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -51,6 +51,29 @@ export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise await clearCachedVaultCoreSnapshot(normalizedKey); } +export async function saveVaultCoreSyncSnapshot( + cacheKey: string, + snapshot: VaultCoreSnapshot, + revisionStamp?: number | null +): Promise { + const normalizedKey = String(cacheKey || '').trim(); + if (!normalizedKey) return; + + const normalizedSnapshot = normalizeCachedSnapshot(snapshot); + const currentMemory = memoryVaultCoreCache.get(normalizedKey); + let nextRevisionStamp = Number(revisionStamp); + if (!Number.isFinite(nextRevisionStamp) || nextRevisionStamp <= 0) { + const cached = await loadCachedVaultCoreSnapshot(normalizedKey); + nextRevisionStamp = currentMemory?.revisionStamp || cached?.revisionStamp || Date.now(); + } + + memoryVaultCoreCache.set(normalizedKey, { + revisionStamp: nextRevisionStamp, + snapshot: normalizedSnapshot, + }); + await saveCachedVaultCoreSnapshot(normalizedKey, nextRevisionStamp, normalizedSnapshot); +} + export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise { const normalizedKey = String(cacheKey || '').trim(); if (!normalizedKey) return { ciphers: [], folders: [], sends: [] }; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 8bfbe0f..0ff375e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -43,7 +43,7 @@ export async function createFolder( authedFetch: AuthedFetch, session: SessionState, name: string -): Promise<{ id: string; name?: string | null }> { +): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); @@ -54,9 +54,9 @@ export async function createFolder( body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Create folder failed'); - const body = await parseJson<{ id?: string; name?: string | null }>(resp); + const body = await parseJson(resp); if (!body?.id) throw new Error('Create folder failed'); - return { id: body.id, name: body.name ?? null }; + return body; } export async function encryptFolderImportName(session: SessionState, name: string): Promise { @@ -92,7 +92,7 @@ export async function updateFolder( session: SessionState, folderId: string, name: string -): Promise { +): Promise { const id = String(folderId || '').trim(); if (!id) throw new Error('Folder id is required'); if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); @@ -105,6 +105,9 @@ export async function updateFolder( body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Update folder failed'); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Update folder failed'); + return body; } export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise { From 9a21504f403aa609dff444604a25cdda549b9ba2 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 22 Jun 2026 16:46:55 +0800 Subject: [PATCH 009/157] Fix realtime sync notifications --- src/durable/notifications-hub.ts | 7 +++++-- webapp/src/App.tsx | 3 +-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index ad72f7f..0b9c132 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -174,7 +174,7 @@ function buildSignalRMessagePackInvocation( target: string = 'ReceiveMessage' ): Uint8Array { // SignalR MessagePack hub protocol uses an array-based invocation shape: - // [type, headers, invocationId, target, arguments] + // [type, headers, invocationId, target, arguments, streamIds] const encodedPayload = encodeMsgPack([ 1, {}, @@ -187,6 +187,7 @@ function buildSignalRMessagePackInvocation( Payload: messagePayload, }, ], + [], ]); return frameSignalRBinary(encodedPayload); } @@ -217,7 +218,9 @@ export class NotificationsHub extends DurableObject { const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); const userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || '').trim(); const contextId = String(body?.contextId || '').trim() || null; - const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; + const rawUpdateType = body?.updateType; + const parsedUpdateType = typeof rawUpdateType === 'number' ? rawUpdateType : Number(rawUpdateType); + const updateType = Number.isFinite(parsedUpdateType) ? parsedUpdateType : SIGNALR_UPDATE_TYPE_SYNC_VAULT; const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; const payload = body?.payload && typeof body.payload === 'object' ? body.payload diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 25bb9be..f88c768 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1626,7 +1626,7 @@ export default function App() { continue; } if (contextId && contextId === getCurrentDeviceIdentifier()) continue; - if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS) { + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS || updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) { if (notificationRefreshTimerRef.current !== null) { window.clearTimeout(notificationRefreshTimerRef.current); } @@ -1660,7 +1660,6 @@ export default function App() { deleteSendLocally(resourceId, revisionStamp); continue; } - if (updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; } }); From 79ed7c9f85ec61503d3de68c1fd9a551fa38578d Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 22 Jun 2026 22:09:38 +0800 Subject: [PATCH 010/157] Add Bitwarden push relay support --- README.md | 2 +- README_EN.md | 2 +- migrations/0001_init.sql | 3 + src/durable/notifications-hub.ts | 11 ++ src/handlers/devices.ts | 49 ++++- src/handlers/identity.ts | 42 +++++ src/router-devices.ts | 34 ++-- src/services/push-relay.ts | 275 ++++++++++++++++++++++++++++ src/services/storage-device-repo.ts | 69 ++++++- src/services/storage-schema.ts | 5 +- src/services/storage.ts | 29 ++- src/types/index.ts | 2 + 12 files changed, 492 insertions(+), 31 deletions(-) create mode 100644 src/services/push-relay.ts diff --git a/README.md b/README.md index 79715bd..bde5784 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ | **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | -| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | +| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | Send | ✅ | ✅ | 支持文本与文件 Send | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | diff --git a/README_EN.md b/README_EN.md index c60eb82..aaf5c72 100644 --- a/README_EN.md +++ b/README_EN.md @@ -40,7 +40,7 @@ | **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** | | **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** | | **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** | -| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | +| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Send | ✅ | ✅ | Supports both text and file Sends | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 6755792..668e648 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices ( encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, + push_uuid TEXT, + push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, @@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices ( ); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); +CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token); CREATE TABLE IF NOT EXISTS auth_requests ( id TEXT PRIMARY KEY, diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index 0b9c132..f367bb2 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -1,5 +1,6 @@ import { DurableObject, waitUntil } from 'cloudflare:workers'; import type { Env } from '../types'; +import { notifyMobilePush } from '../services/push-relay'; const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); @@ -767,6 +768,16 @@ async function notifyUserUpdate( }, }), }); + await notifyMobilePush(env, { + userId, + updateType, + revisionDate, + contextId, + payload: payloadOverride || { + UserId: userId, + Date: revisionDate, + }, + }); } catch (error) { console.error('Failed to broadcast realtime notification:', error); } diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 280657d..4a312b6 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -3,6 +3,7 @@ import { Env } from '../types'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { AuthService } from '../services/auth'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; +import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; @@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null, + pushUuid: null, + pushToken: null, devicePendingAuthRequest: null, deviceNote: null, lastSeenAt: null, @@ -325,10 +328,12 @@ export async function handleDeleteDevice( if (!normalized) return errorResponse('Invalid device identifier', 400); const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); if (deleted) { + await unregisterMobilePushDevice(env, device?.pushUuid); AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } @@ -537,10 +542,12 @@ export async function handleDeactivateDevice( if (!normalized) return errorResponse('Invalid device identifier', 400); const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); if (deleted) { + await unregisterMobilePushDevice(env, device?.pushUuid); AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } @@ -557,18 +564,36 @@ export async function handleDeactivateDevice( } // PUT /api/devices/identifier/{deviceIdentifier}/token -// Bitwarden mobile reports push token updates to this endpoint. -// NodeWarden does not implement push notifications, so accept and no-op. +// Bitwarden mobile reports APNs/FCM push token updates to this endpoint. export async function handleUpdateDeviceToken( request: Request, env: Env, userId: string, deviceIdentifier: string ): Promise { - void request; - void env; - void userId; - void deviceIdentifier; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const body = await readJsonBody(request); + const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim(); + if (!pushToken) return errorResponse('Invalid push token', 400); + + const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); + if (!device) return errorResponse('Device not found', 404); + + const pushUuid = device.pushUuid || generateUUID(); + const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken); + if (updated) { + await registerMobilePushDevice(env, { + userId, + deviceIdentifier: normalized, + type: device.type, + pushUuid, + pushToken, + }); + } + return new Response(null, { status: 200 }); } @@ -594,9 +619,15 @@ export async function handleClearDeviceToken( deviceIdentifier: string ): Promise { void request; - void env; - void userId; - void deviceIdentifier; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + const cleared = await storage.clearDevicePushToken(userId, normalized); + if (cleared?.pushUuid) { + await unregisterMobilePushDevice(env, cleared.pushUuid); + } + return new Response(null, { status: 200 }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 0701c2e..3a15102 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { generateUUID } from '../utils/uuid'; import { issueSendAccessToken } from './sends'; +import { registerMobilePushDevice } from '../services/push-relay'; import { buildAccountKeys, buildUserDecryptionOptions, @@ -50,6 +51,44 @@ async function resolveDeviceSession( return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; } +function readDevicePushToken(body: Record): string { + return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); +} + +async function persistIdentityDevicePushToken( + env: Env, + storage: StorageService, + userId: string, + deviceSession: { identifier: string; sessionStamp: string } | null, + deviceType: number, + body: Record +): Promise { + if (!deviceSession) return; + const pushToken = readDevicePushToken(body); + if (!pushToken) return; + + const device = await storage.getDevice(userId, deviceSession.identifier); + if (!device) return; + + const pushUuid = device.pushUuid || generateUUID(); + await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken); + const registered = await registerMobilePushDevice(env, { + userId, + deviceIdentifier: deviceSession.identifier, + type: device.type || deviceType, + pushUuid, + pushToken, + }); + console.info('Mobile push token updated from identity token request', { + userId, + deviceIdentifier: deviceSession.identifier, + deviceType: device.type || deviceType, + pushUuid, + pushTokenLength: pushToken.length, + relayRegistered: registered, + }); +} + function shouldUseWebSession(request: Request): boolean { return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; } @@ -414,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts @@ -536,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } await rateLimit.clearLoginAttempts(loginIdentifier); @@ -664,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts diff --git a/src/router-devices.ts b/src/router-devices.ts index c6730c1..195e110 100644 --- a/src/router-devices.ts +++ b/src/router-devices.ts @@ -20,6 +20,10 @@ import { handleClearDeviceToken, } from './handlers/devices'; +function devicesPath(pattern: string): RegExp { + return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i'); +} + export async function handleAuthenticatedDeviceRoute( request: Request, env: Env, @@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute( path: string, method: string ): Promise { - if (path === '/api/devices') { + if (path === '/api/devices' || path === '/devices') { if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); return null; } - if (path === '/api/devices/authorized') { + if (path === '/api/devices/authorized' || path === '/devices/authorized') { if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); return null; } - const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); + const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)')); if (authorizedDeviceMatch && method === 'DELETE') { const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); } - const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); + const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent')); if (permanentAuthorizedDeviceMatch && method === 'POST') { const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); } - const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); + const deleteDeviceMatch = path.match(devicesPath('/([^/]+)')); if (deleteDeviceMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); return handleGetDevice(request, env, userId, deviceIdentifier); @@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute( return handleDeleteDevice(request, env, userId, deviceIdentifier); } - const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); + const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name')); if (updateDeviceNameMatch && method === 'PUT') { const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); return handleUpdateDeviceName(request, env, userId, deviceIdentifier); } - const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); + const identifierMatch = path.match(devicesPath('/identifier/([^/]+)')); if (identifierMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(identifierMatch[1]); return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); } - const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i); + const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys')); if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]); return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier); } - const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); + const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token')); if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); } - const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i); + const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth')); if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]); return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier); } - const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i); + const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token')); if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]); return handleClearDeviceToken(request, env, userId, deviceIdentifier); } - const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i); + const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys')); if (identifierRetrieveKeysMatch && method === 'POST') { const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]); return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier); } - const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i); + const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate')); if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) { const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]); return handleDeactivateDevice(request, env, userId, deviceIdentifier); } - if (path === '/api/devices/update-trust' && method === 'POST') { + if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') { return handleUpdateDeviceTrust(request, env, userId); } - if (path === '/api/devices/untrust' && method === 'POST') { + if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') { return handleUntrustDevices(request, env, userId); } diff --git a/src/services/push-relay.ts b/src/services/push-relay.ts new file mode 100644 index 0000000..e65cab0 --- /dev/null +++ b/src/services/push-relay.ts @@ -0,0 +1,275 @@ +import type { Env } from '../types'; +import { + setConfigValue as saveConfigValue, +} from './storage-config-repo'; + +const PUSH_RELAY_URI = 'https://push.bitwarden.com'; +const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com'; +const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations'; +const PUSH_INSTALLATION_ID_KEY = 'push.installation.id'; +const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key'; +const PUSH_REQUEST_TIMEOUT_MS = 5000; + +interface CachedPushAccessToken { + token: string; + expiresAt: number; +} + +let cachedPushAccessToken: CachedPushAccessToken | null = null; + +async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + console.error(errorMessage, error); + return null; + } finally { + clearTimeout(timeout); + } +} + +function randomInstallationEmail(): string { + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join(''); + return `${localPart}@nodewarden.app`; +} + +async function getConfigKeyPresence(db: D1Database, key: string): Promise { + const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>(); + return typeof row?.value === 'string' ? row.value : null; +} + +async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> { + const [id, key] = await Promise.all([ + getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY), + getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY), + ]); + const normalizedId = String(id || '').trim(); + const normalizedKey = String(key || '').trim(); + return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null; +} + +export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> { + const existing = await getPushInstallationCredentials(db); + if (existing) return existing; + + const response = await fetchPushEndpoint( + INSTALLATIONS_URI, + { + method: 'POST', + headers: { + accept: 'application/json', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'cache-control': 'no-cache', + 'content-type': 'application/json', + origin: 'https://bitwarden.com', + pragma: 'no-cache', + priority: 'u=1, i', + referer: 'https://bitwarden.com/host/', + 'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-site', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ + formName: 'request_host', + url: '/host/', + locale: 'zh-CN', + email: randomInstallationEmail(), + region: 'us', + }), + }, + 'Failed to request Bitwarden push installation:' + ); + if (!response) return null; + + if (!response.ok) { + console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => '')); + return null; + } + + const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null; + const id = String(body?.id || '').trim(); + const key = String(body?.key || '').trim(); + if (!id || !key) { + console.error('Bitwarden push installation response did not include id/key'); + return null; + } + + await Promise.all([ + saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id), + saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key), + ]); + return { id, key }; +} + +async function getPushAccessToken(env: Env): Promise { + const credentials = await ensurePushInstallationCredentials(env.DB); + if (!credentials) return null; + + const now = Date.now(); + if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) { + return cachedPushAccessToken.token; + } + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + scope: 'api.push', + client_id: `installation.${credentials.id}`, + client_secret: credentials.key, + }); + + const response = await fetchPushEndpoint( + `${PUSH_IDENTITY_URI}/connect/token`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }, + 'Failed to get Bitwarden push relay token:' + ); + if (!response) return null; + + if (!response.ok) { + console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => '')); + return null; + } + + const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null; + const token = String(body?.access_token || '').trim(); + if (!token) { + console.error('Bitwarden push relay token response did not include an access_token'); + return null; + } + + const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600)); + cachedPushAccessToken = { + token, + expiresAt: now + Math.floor(expiresInSeconds * 500), + }; + return token; +} + +async function postToPushRelay(env: Env, path: string, body?: unknown): Promise { + const token = await getPushAccessToken(env); + if (!token) return false; + + const response = await fetchPushEndpoint( + `${PUSH_RELAY_URI}${path}`, + { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + `Bitwarden push relay request failed: ${path}` + ); + if (!response) return false; + + if (!response.ok) { + console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => '')); + return false; + } + + return true; +} + +function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record | null | undefined): Record { + const source = payload || {}; + const id = source.Id ?? source.id; + const organizationId = source.OrganizationId ?? source.organizationId ?? null; + const collectionIds = source.CollectionIds ?? source.collectionIds ?? null; + + if (id != null) { + return { + id, + userId: source.UserId ?? source.userId ?? userId, + organizationId, + collectionIds, + revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate, + }; + } + + return { + userId: source.UserId ?? source.userId ?? userId, + date: source.Date ?? source.date ?? revisionDate, + }; +} + +export async function registerMobilePushDevice( + env: Env, + input: { + userId: string; + deviceIdentifier: string; + type: number; + pushUuid: string; + pushToken: string; + } +): Promise { + const credentials = await ensurePushInstallationCredentials(env.DB); + if (!credentials) return false; + + return postToPushRelay(env, '/push/register', { + deviceId: input.pushUuid, + pushToken: input.pushToken, + userId: input.userId, + type: input.type, + identifier: input.deviceIdentifier, + installationId: credentials.id, + }); +} + +export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise { + const normalized = String(pushUuid || '').trim(); + if (!normalized) return false; + return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`); +} + +export async function notifyMobilePush( + env: Env, + input: { + userId: string; + updateType: number; + revisionDate: string; + contextId: string | null; + payload: Record | null | undefined; + } +): Promise { + const hasPushDevice = await env.DB + .prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1') + .bind(input.userId, '') + .first<{ '1': number }>(); + if (!hasPushDevice) return; + + let actingPushUuid: string | null = null; + if (input.contextId) { + const row = await env.DB + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(input.userId, input.contextId) + .first<{ push_uuid: string | null }>(); + actingPushUuid = row?.push_uuid ?? null; + } + + await postToPushRelay(env, '/push/send', { + userId: input.userId, + organizationId: null, + deviceId: actingPushUuid, + identifier: input.contextId, + type: input.updateType, + payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload), + clientType: null, + installationId: null, + }); +} diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index 1a93cfd..3bd33fa 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -1,4 +1,5 @@ import type { Device, TrustedDeviceTokenSummary, User } from '../types'; +import { generateUUID } from '../utils/uuid'; type GetUserByEmail = (email: string) => Promise; type TrustedTokenKeyFn = (token: string) => Promise; @@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device { encryptedUserKey: row.encrypted_user_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null, + pushUuid: row.push_uuid ?? null, + pushToken: row.push_token ?? null, lastSeenAt: row.last_seen_at ?? null, createdAt: row.created_at, updatedAt: row.updated_at, @@ -38,13 +41,15 @@ export async function upsertDevice( const existingDevice = await getDeviceById(userId, deviceIdentifier); const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); + const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID(); await db .prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + + 'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' + 'last_seen_at=excluded.last_seen_at, ' + 'updated_at=excluded.updated_at' ) @@ -57,6 +62,7 @@ export async function upsertDevice( keys?.encryptedUserKey ?? null, keys?.encryptedPublicKey ?? null, keys?.encryptedPrivateKey ?? null, + effectivePushUuid, existingDevice?.deviceNote ?? null, now, now, @@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail( export async function getDevicesByUserId(db: D1Database, userId: string): Promise { const res = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' ) .bind(userId) @@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const row = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' ) .bind(userId, deviceIdentifier) @@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier return row ? mapDeviceRow(row) : null; } +export async function updateDevicePushToken( + db: D1Database, + userId: string, + deviceIdentifier: string, + pushUuid: string, + pushToken: string +): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + 'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' + + 'WHERE user_id = ? AND device_identifier = ?' + ) + .bind(pushUuid, pushToken, now, userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function clearDevicePushToken( + db: D1Database, + userId: string, + deviceIdentifier: string +): Promise<{ pushUuid: string | null } | null> { + const existing = await db + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(userId, deviceIdentifier) + .first<{ push_uuid: string | null }>(); + if (!existing) return null; + + await db + .prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?') + .bind(new Date().toISOString(), userId, deviceIdentifier) + .run(); + + return { pushUuid: existing.push_uuid ?? null }; +} + +export async function getDevicePushUuid( + db: D1Database, + userId: string, + deviceIdentifier: string +): Promise { + const row = await db + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(userId, deviceIdentifier) + .first<{ push_uuid: string | null }>(); + return row?.push_uuid ?? null; +} + +export async function userHasPushDevice(db: D1Database, userId: string): Promise { + const row = await db + .prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1') + .bind(userId, '') + .first<{ '1': number }>(); + return !!row; +} + export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const result = await db .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index babb48e..bdf6458 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE TABLE IF NOT EXISTS devices (' + - 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'PRIMARY KEY (user_id, device_identifier), ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', @@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', + 'ALTER TABLE devices ADD COLUMN push_uuid TEXT', + 'ALTER TABLE devices ADD COLUMN push_token TEXT', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN device_note TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', + 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE TABLE IF NOT EXISTS auth_requests (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index fc23b73..980fc43 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,5 +1,6 @@ import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types'; import { LIMITS } from '../config/limits'; +import { ensurePushInstallationCredentials } from './push-relay'; import { ensureStorageSchema } from './storage-schema'; import { getConfigValue as getStoredConfigValue, @@ -87,10 +88,12 @@ import { import { deleteDevice as deleteStoredDevice, deleteDevicesByUserId as deleteStoredDevicesByUserId, + clearDevicePushToken as clearStoredDevicePushToken, clearDeviceKeys as clearStoredDeviceKeys, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, getDevice as findStoredDevice, + getDevicePushUuid as findStoredDevicePushUuid, getDevicesByUserId as listStoredDevicesByUserId, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, @@ -101,7 +104,9 @@ import { upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, + updateDevicePushToken as updateStoredDevicePushToken, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, + userHasPushDevice as getUserHasPushDevice, } from './storage-device-repo'; import { createAuthRequest as createStoredAuthRequest, @@ -143,7 +148,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests'; +const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; // D1-backed storage. @@ -235,6 +240,7 @@ export class StorageService { await ensureStorageSchema(this.db); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); } + await ensurePushInstallationCredentials(this.db); StorageService.schemaVerified = true; } @@ -713,6 +719,27 @@ export class StorageService { return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); } + async updateDevicePushToken( + userId: string, + deviceIdentifier: string, + pushUuid: string, + pushToken: string + ): Promise { + return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken); + } + + async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> { + return clearStoredDevicePushToken(this.db, userId, deviceIdentifier); + } + + async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise { + return findStoredDevicePushUuid(this.db, userId, deviceIdentifier); + } + + async userHasPushDevice(userId: string): Promise { + return getUserHasPushDevice(this.db, userId); + } + async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise { return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); } diff --git a/src/types/index.ts b/src/types/index.ts index 245773b..8ffe519 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -231,6 +231,8 @@ export interface Device { encryptedUserKey: string | null; encryptedPublicKey: string | null; encryptedPrivateKey: string | null; + pushUuid: string | null; + pushToken: string | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null; lastSeenAt: string | null; createdAt: string; From 4900de0444dcb76fcc39c6e2aba0fadabbe8a0b0 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 22 Jun 2026 22:09:54 +0800 Subject: [PATCH 011/157] Refresh auth requests from realtime notifications --- webapp/src/App.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f88c768..7bf7127 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -147,6 +147,8 @@ const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12; const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13; const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14; +const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15; +const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; @@ -260,6 +262,7 @@ export default function App() { const sessionRef = useRef(initialBootstrap.session); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); + const refreshPendingAuthRequestsRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); const uriChecksumRepairAttemptRef = useRef(''); const pendingVaultCoreQueryRefreshRef = useRef | null>(null); @@ -1083,8 +1086,9 @@ export default function App() { enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); + const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQuery = useQuery({ - queryKey: ['auth-requests-pending', vaultCacheKey || session?.email], + queryKey: pendingAuthRequestsQueryKey, queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email), staleTime: 5_000, @@ -1621,6 +1625,10 @@ export default function App() { void refreshAuthorizedDevicesRef.current(); continue; } + if (updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST || updateType === SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE) { + void refreshPendingAuthRequestsRef.current(); + continue; + } if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; @@ -1775,6 +1783,11 @@ export default function App() { if (!vaultInitialDecryptDone) return; await authorizedDevicesQuery.refetch(); }; + refreshPendingAuthRequestsRef.current = async () => { + if (!vaultInitialDecryptDone || !(profile?.email || session?.email)) return; + setAuthRequestDialogDismissedId(null); + await pendingAuthRequestsQuery.refetch(); + }; const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; From 16bde226041e33660e2b922821c4bdda238c87de Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 22 Jun 2026 22:30:29 +0800 Subject: [PATCH 012/157] Unify mobile topbar controls --- webapp/src/components/ThemeSwitch.tsx | 2 +- webapp/src/styles/responsive.css | 48 +++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/ThemeSwitch.tsx b/webapp/src/components/ThemeSwitch.tsx index 08ebce3..a6de895 100644 --- a/webapp/src/components/ThemeSwitch.tsx +++ b/webapp/src/components/ThemeSwitch.tsx @@ -7,7 +7,7 @@ interface ThemeSwitchProps { export default function ThemeSwitch(props: ThemeSwitchProps) { return (
-
- - -
From 1acc31eda0870e161f3dc63856617dc1e6181d11 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 25 Jun 2026 18:42:34 +0800 Subject: [PATCH 025/157] feat: add Backblaze B2 support with recommendations and styling updates --- .../backup-center/BackupDestinationDetail.tsx | 25 +++++++++++++++++++ webapp/src/lib/backup-recommendations.ts | 19 ++++++++++++-- webapp/src/lib/i18n/locales/en.ts | 8 ++++++ webapp/src/lib/i18n/locales/es.ts | 8 ++++++ webapp/src/lib/i18n/locales/ru.ts | 8 ++++++ webapp/src/lib/i18n/locales/zh-CN.ts | 20 ++++++++++----- webapp/src/lib/i18n/locales/zh-TW.ts | 8 ++++++ webapp/src/styles/dark.css | 9 +++++++ webapp/src/styles/management.css | 12 +++++++++ 9 files changed, 109 insertions(+), 8 deletions(-) diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 147e390..d4b5ac4 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -127,6 +127,30 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
); + case 'backblaze-b2': + return ( +
+
+ 1. {t('txt_backup_recommend_backblaze_step_1')} +
+
+ 2. {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '} + Buckets + {t('txt_backup_recommend_backblaze_step_2_suffix')} +
+
+ 3. {t('txt_backup_recommend_backblaze_step_3')} +
+
+ 4. {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '} + Application Keys + {t('txt_backup_recommend_backblaze_step_4_suffix')} +
+
+ 5. {t('txt_backup_recommend_backblaze_step_5')} +
+
+ ); } } @@ -147,6 +171,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') + : props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary') : t('txt_backup_recommend_pcloud_summary')}
diff --git a/webapp/src/lib/backup-recommendations.ts b/webapp/src/lib/backup-recommendations.ts index 1cc2f36..d8c3caa 100644 --- a/webapp/src/lib/backup-recommendations.ts +++ b/webapp/src/lib/backup-recommendations.ts @@ -4,7 +4,7 @@ export interface RecommendedStorageLink { } export interface RecommendedProviderBase { - id: 'infinicloud' | 'koofr' | 'pcloud'; + id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2'; name: string; capacity: string; protocol: 'webdav' | 's3'; @@ -28,7 +28,13 @@ export interface PcloudProvider extends RecommendedProviderBase { id: 'pcloud'; } -export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider; +export interface BackblazeB2Provider extends RecommendedProviderBase { + id: 'backblaze-b2'; + bucketsUrl: string; + applicationKeysUrl: string; +} + +export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider; export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ { @@ -61,6 +67,15 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7', hasAffiliateLink: true, }, + { + id: 'backblaze-b2', + name: 'Backblaze B2', + capacity: '10G', + protocol: 's3', + signupUrl: 'https://secure.backblaze.com/user_signin.htm', + bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm', + applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm', + }, ]; export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider { diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 51010c7..9bf65b3 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -85,6 +85,14 @@ const en: Record = { "txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.", "txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.", + "txt_backup_recommend_backblaze_summary": "S3-compatible object storage with a 10 GB free allowance. Use the B2 S3 endpoint, bucket name, region, keyID, and applicationKey.", + "txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.", + "txt_backup_recommend_backblaze_step_2_prefix": "Open", + "txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.", + "txt_backup_recommend_backblaze_step_3": "After creation, use the displayed Endpoint as the S3 endpoint, the bucket name as the bucket, and the middle part of the endpoint such as us-west-004 as the region.", + "txt_backup_recommend_backblaze_step_4_prefix": "Open", + "txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.", + "txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.", "txt_backup_add_destination": "Add Destination", "txt_backup_schedule_panel_title": "Automatic Schedule", "txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 5b22444..d413275 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -85,6 +85,14 @@ const es: Record = { "txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.", "txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.", + "txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis. Use el endpoint S3 de B2, el nombre del bucket, la región, keyID y applicationKey.", + "txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.", + "txt_backup_recommend_backblaze_step_2_prefix": "Abra", + "txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.", + "txt_backup_recommend_backblaze_step_3": "Después de crearlo, use el Endpoint mostrado como endpoint S3, el nombre del bucket como bucket y la parte central del endpoint, como us-west-004, como región.", + "txt_backup_recommend_backblaze_step_4_prefix": "Abra", + "txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.", + "txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.", "txt_backup_add_destination": "Añadir destino", "txt_backup_schedule_panel_title": "Programación automática", "txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 66ddfbf..b117b5e 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -86,6 +86,14 @@ const ru: Record = { "txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.", "txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.", "txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.", + "txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ. Используйте S3 endpoint B2, имя bucket, регион, keyID и applicationKey.", + "txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.", + "txt_backup_recommend_backblaze_step_2_prefix": "Откройте", + "txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.", + "txt_backup_recommend_backblaze_step_3": "После создания используйте показанный Endpoint как S3 endpoint, имя bucket как bucket, а среднюю часть endpoint, например us-west-004, как регион.", + "txt_backup_recommend_backblaze_step_4_prefix": "Откройте", + "txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.", + "txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.", "txt_backup_add_destination": "Добавить пункт назначения", "txt_backup_schedule_panel_title": "Автоматическое расписание", "txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index a388606..9bf0a73 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -85,6 +85,14 @@ const zhCN: Record = { "txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。", + "txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。", + "txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。", + "txt_backup_recommend_backblaze_step_2_prefix": "打开", + "txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。", + "txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3端点URL;桶名名称 填到 储桶名称;区域填写 Endpoint 中间的区域值,例如 us-west-004。", + "txt_backup_recommend_backblaze_step_4_prefix": "打开", + "txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。", + "txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 ID,applicationKey 填到 访问密码。", "txt_backup_add_destination": "新增地点", "txt_backup_schedule_panel_title": "自动备份计划", "txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。", @@ -266,15 +274,15 @@ const zhCN: Record = { "txt_backup_webdav_username": "WebDAV 用户名", "txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_path": "远程目录", - "txt_backup_s3_endpoint": "S3 端点", - "txt_backup_s3_addressing_style": "S3 寻址方式", + "txt_backup_s3_endpoint": "S3 端点 URL", + "txt_backup_s3_addressing_style": "寻址方式", "txt_backup_s3_addressing_path_style": "path-style(默认)", "txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style", - "txt_backup_s3_bucket": "存储桶", + "txt_backup_s3_bucket": "存储桶名称", "txt_backup_s3_region": "区域", - "txt_backup_s3_access_key": "访问密钥", - "txt_backup_s3_secret_key": "秘密密钥", - "txt_backup_s3_path": "远程路径", + "txt_backup_s3_access_key": "访问 ID", + "txt_backup_s3_secret_key": "访问密码", + "txt_backup_s3_path": "路径前缀", "txt_backup_reserved_name": "预留类型名称", "txt_backup_reserved_notes": "预留备注", "txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 230b8a4..7f884b2 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -85,6 +85,14 @@ const zhTW: Record = { "txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。", + "txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB。需要填寫 B2 的 S3 端點、桶名、區域、keyID 和 applicationKey。", + "txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。", + "txt_backup_recommend_backblaze_step_2_prefix": "打開", + "txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。", + "txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 就是 S3 端點;桶名字就是儲存桶;區域填寫 Endpoint 中間的區域值,例如 us-west-004。", + "txt_backup_recommend_backblaze_step_4_prefix": "打開", + "txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。", + "txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。", "txt_backup_add_destination": "新增地點", "txt_backup_schedule_panel_title": "自動備份計劃", "txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。", diff --git a/webapp/src/styles/dark.css b/webapp/src/styles/dark.css index b7cf91c..6e43780 100644 --- a/webapp/src/styles/dark.css +++ b/webapp/src/styles/dark.css @@ -351,6 +351,15 @@ color: var(--muted); } +:root[data-theme='dark'] .backup-recommendation-step a { + color: var(--primary); +} + +:root[data-theme='dark'] .backup-recommendation-step a:hover, +:root[data-theme='dark'] .backup-recommendation-step a:focus-visible { + color: var(--primary-strong); +} + :root[data-theme='dark'] .restore-progress-overlay { background: var(--overlay-strong); backdrop-filter: blur(8px); diff --git a/webapp/src/styles/management.css b/webapp/src/styles/management.css index 57f1e12..820a614 100644 --- a/webapp/src/styles/management.css +++ b/webapp/src/styles/management.css @@ -193,6 +193,18 @@ line-height: 1.5; } +.backup-recommendation-step a { + color: #1d4ed8; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 2px; +} + +.backup-recommendation-step a:hover, +.backup-recommendation-step a:focus-visible { + color: #1742b0; +} + .backup-recommendation-inline-note { color: #475467; line-height: 1.5; From c3dc53bac15802c999c46bcc2eb11fe01b836789 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 25 Jun 2026 19:45:09 +0800 Subject: [PATCH 026/157] feat: add Cloudflare R2 support with detailed backup recommendations and localization updates --- shared/backup-schema.ts | 7 ++-- .../backup-center/BackupDestinationDetail.tsx | 29 +++++++++++++-- .../backup-center/BackupOperationsSidebar.tsx | 35 ++++++++++++++++++- webapp/src/lib/backup-recommendations.ts | 19 ++++++++-- webapp/src/lib/i18n/locales/en.ts | 10 ++++++ webapp/src/lib/i18n/locales/es.ts | 10 ++++++ webapp/src/lib/i18n/locales/ru.ts | 10 ++++++ webapp/src/lib/i18n/locales/zh-CN.ts | 12 ++++++- webapp/src/lib/i18n/locales/zh-TW.ts | 10 ++++++ 9 files changed, 133 insertions(+), 9 deletions(-) diff --git a/shared/backup-schema.ts b/shared/backup-schema.ts index 2478a6f..f1fb591 100644 --- a/shared/backup-schema.ts +++ b/shared/backup-schema.ts @@ -9,7 +9,8 @@ export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_S3_REGION = 'auto'; -export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; +export const BACKUP_DEFAULT_S3_ROOT_PATH = ''; +export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_START_TIME = '03:00'; @@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType region: BACKUP_DEFAULT_S3_REGION, accessKeyId: '', secretAccessKey: '', - rootPath: BACKUP_DEFAULT_REMOTE_PATH, + rootPath: BACKUP_DEFAULT_S3_ROOT_PATH, }; } return { baseUrl: '', username: '', password: '', - remotePath: BACKUP_DEFAULT_REMOTE_PATH, + remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH, }; } diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index d4b5ac4..615e749 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -151,6 +151,30 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) { ); + case 'cloudflare-r2': + return ( +
+
+ 1. {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '} + {t('txt_backup_recommend_cloudflare_r2_bucket_link')} + {t('txt_backup_recommend_cloudflare_r2_step_1_suffix')} +
+
+ 2. {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '} + {t('txt_backup_recommend_cloudflare_r2_api_link')} + {t('txt_backup_recommend_cloudflare_r2_step_2_suffix')} +
+
+ 3. {t('txt_backup_recommend_cloudflare_r2_step_3')} +
+
+ 4. {t('txt_backup_recommend_cloudflare_r2_step_4')} +
+
+ 5. {t('txt_backup_recommend_cloudflare_r2_step_5')} +
+
+ ); } } @@ -172,6 +196,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { {props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') : props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary') + : props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary') : t('txt_backup_recommend_pcloud_summary')} @@ -412,7 +437,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { className="input" value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath} disabled={props.loadingSettings || props.disableWhileBusy} - placeholder="nodewarden/backups" + placeholder="nodewarden" onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, destination: { @@ -529,7 +554,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { className="input" value={(props.selectedDestination.destination as S3BackupDestination).rootPath} disabled={props.loadingSettings || props.disableWhileBusy} - placeholder="nodewarden/backups" + placeholder="" onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, destination: { diff --git a/webapp/src/components/backup-center/BackupOperationsSidebar.tsx b/webapp/src/components/backup-center/BackupOperationsSidebar.tsx index 1b9720c..6568667 100644 --- a/webapp/src/components/backup-center/BackupOperationsSidebar.tsx +++ b/webapp/src/components/backup-center/BackupOperationsSidebar.tsx @@ -1,9 +1,12 @@ import { Download, FileUp } from 'lucide-preact'; +import { useEffect, useState } from 'preact/hooks'; import type { RecommendedProvider } from '@/lib/backup-recommendations'; import { hasLinkedStorages } from '@/lib/backup-recommendations'; import { t } from '@/lib/i18n'; import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; +const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)'; + interface BackupOperationsSidebarProps { disableWhileBusy: boolean; exporting: boolean; @@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps { onSelectProvider: (providerId: string) => void; } +function getDefaultRecommendationsOpen() { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return true; + } + return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches; +} + export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) { + const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen); + const [recommendationsTouched, setRecommendationsTouched] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) { + return; + } + + const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY); + const syncOpenState = () => setRecommendationsOpen(!media.matches); + + syncOpenState(); + media.addEventListener('change', syncOpenState); + return () => media.removeEventListener('change', syncOpenState); + }, [recommendationsTouched]); + return (