From 70463d3fc7fc491ae9a037b731e50945bdd1fa19 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 9 Apr 2026 16:50:43 +0800 Subject: [PATCH 1/6] feat: add Telegram channel and group links to README files --- README.md | 2 ++ README_EN.md | 1 + 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index e4c814c..63bca1e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ [更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest) +[Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official) + English: [`README_EN.md`](./README_EN.md) > **免责声明** diff --git a/README_EN.md b/README_EN.md index 0d01131..258db8c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -10,6 +10,7 @@ [![Latest Release](https://img.shields.io/github/v/release/shuaiplus/NodeWarden?display_name=tag)](https://github.com/shuaiplus/NodeWarden/releases/latest) [![Sync Upstream](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml/badge.svg)](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml) [Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest) +[Telegram Channel](https://t.me/NodeWarden_News) | [Telegram Group](https://t.me/NodeWarden_Official) 中文说明:[`README.md`](./README.md) > **Disclaimer** From 34d485198112d441ca666c6e219c0ddf876aff3f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 9 Apr 2026 17:05:52 +0800 Subject: [PATCH 2/6] feat: add links to documentation homepage and quick start in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 63bca1e..7d5859d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ [更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest) +[文档首页](./nodewarden.wiki/Home.md) | [快速开始](./nodewarden.wiki/快速开始.md) + [Telegram 频道](https://t.me/NodeWarden_News) | [Telegram 群组](https://t.me/NodeWarden_Official) English: [`README_EN.md`](./README_EN.md) From 4d7ee2164a40b4d3f9c3829acaffade722077551 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:27:22 +0000 Subject: [PATCH 3/6] chore: restore sync-upstream workflow after sync --- .github/workflows/sync-upstream.yml | 125 ++++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 069af43..2925833 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -4,6 +4,11 @@ on: schedule: - cron: "0 3 * * *" workflow_dispatch: + inputs: + target_commit: + description: 'Commit hash (leave blank to use latest commit)' + required: false + type: string permissions: contents: write @@ -11,9 +16,8 @@ permissions: jobs: sync: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -22,13 +26,118 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Sync main from upstream + - name: Add upstream run: | git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true - git fetch upstream - git checkout main - git merge upstream/main + git fetch upstream --tags - - name: Push synced main + - name: Resolve target commit + id: resolve run: | - git push origin main + TRIGGER="${{ github.event_name }}" + MANUAL_INPUT="${{ github.event.inputs.target_commit }}" + + if [ "$TRIGGER" = "schedule" ]; then + # Auto mode: resolve latest upstream release tag + LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name) + if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then + echo "No release found in upstream." + exit 1 + fi + TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null) + if [ -z "$TARGET_SHA" ]; then + echo "Tag '$LATEST_TAG' not found after fetch." + exit 1 + fi + echo "mode=auto" >> $GITHUB_OUTPUT + echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT + echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT + echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)" + + elif [ -n "$MANUAL_INPUT" ]; then + # Manual mode: use provided commit hash or tag + TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null) + if [ -z "$TARGET_SHA" ]; then + echo "Cannot resolve '$MANUAL_INPUT' to a commit." + exit 1 + fi + echo "mode=manual" >> $GITHUB_OUTPUT + echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT + echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)" + + else + # Manual mode, blank input: use latest commit on upstream/main + TARGET_SHA=$(git rev-parse upstream/main) + echo "mode=manual" >> $GITHUB_OUTPUT + echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT + echo "Manual mode — latest commit: $TARGET_SHA" + fi + + - name: Check if update is needed + id: check + run: | + TARGET_SHA="${{ steps.resolve.outputs.target_sha }}" + MODE="${{ steps.resolve.outputs.mode }}" + + if [ "$MODE" = "manual" ]; then + # Manual: skip only if HEAD is exactly this commit + CURRENT_SHA=$(git rev-parse HEAD) + if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then + echo "Already at $TARGET_SHA — skipping." + echo "needs_update=false" >> $GITHUB_OUTPUT + else + echo "Switching to $TARGET_SHA" + echo "needs_update=true" >> $GITHUB_OUTPUT + fi + else + # Auto: skip if target is already in ancestry + if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then + echo "Already up to date with $TARGET_SHA — skipping." + echo "needs_update=false" >> $GITHUB_OUTPUT + else + echo "Update needed — target: $TARGET_SHA" + echo "needs_update=true" >> $GITHUB_OUTPUT + fi + fi + + - name: Apply update + if: steps.check.outputs.needs_update == 'true' + run: | + TARGET_SHA="${{ steps.resolve.outputs.target_sha }}" + MODE="${{ steps.resolve.outputs.mode }}" + git checkout main + if [ "$MODE" = "manual" ]; then + # Hard reset allows both upgrade and rollback + git reset --hard "$TARGET_SHA" + else + git merge "$TARGET_SHA" --no-edit + fi + + - name: Restore workflow file + if: steps.check.outputs.needs_update == 'true' + run: | + # Always keep our own workflow file, never let upstream overwrite it + git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true + if ! git diff --cached --quiet; then + git commit -m "chore: restore sync-upstream workflow after sync" + fi + + - name: Push + if: steps.check.outputs.needs_update == 'true' + run: | + if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then + git push origin main --force + else + git push origin main + fi + + - name: Summary + run: | + if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then + echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY + echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY + else + echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY + fi From a982a5a57b7aab2e8326e79c2f0eab7ebafeff5f Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 9 Apr 2026 23:05:00 +0800 Subject: [PATCH 4/6] feat: enhance database indexing and optimize sync response handling --- migrations/0001_init.sql | 2 + src/config/limits.ts | 3 + src/handlers/accounts.ts | 3 +- src/handlers/attachments.ts | 17 +++- src/handlers/ciphers.ts | 8 +- src/handlers/identity.ts | 20 ++-- src/handlers/sends-private.ts | 3 +- src/handlers/sync.ts | 159 ++++++++++--------------------- src/services/storage-schema.ts | 2 + webapp/src/lib/api/send.ts | 7 +- webapp/src/lib/api/vault-sync.ts | 31 ++++++ webapp/src/lib/api/vault.ts | 14 +-- 12 files changed, 129 insertions(+), 140 deletions(-) create mode 100644 webapp/src/lib/api/vault-sync.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 8053368..3d1da10 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers ( CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at); CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); +CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at); CREATE TABLE IF NOT EXISTS folders ( id TEXT PRIMARY KEY, @@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends ( ); CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date); +CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id); CREATE TABLE IF NOT EXISTS refresh_tokens ( token TEXT PRIMARY KEY, diff --git a/src/config/limits.ts b/src/config/limits.ts index b85d275..8eb4a4c 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -130,6 +130,9 @@ // Max total items (folders + ciphers) allowed in a single import. // 单次导入允许的最大条目数(文件夹 + 密码项合计)。 importItemLimit: 5000, + // Small fixed concurrency for blob/attachment batch cleanup work. + // 附件 / blob 批量清理时的保守并发数。 + attachmentDeleteConcurrency: 4, }, request: { // Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt. diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index b7d5603..9b4204b 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -87,6 +87,7 @@ async function verifyUserSecret( function toProfile(user: User, env: Env): ProfileResponse { void env; + const accountKeys = buildAccountKeys(user); return { id: user.id, name: user.name, @@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse { twoFactorEnabled: !!user.totpSecret, key: user.key, privateKey: user.privateKey, - accountKeys: buildAccountKeys(user), + accountKeys, securityStamp: user.securityStamp || user.id, organizations: [], providers: [], diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 403b25f..91a81d2 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -38,6 +38,18 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T) => Promise +): Promise { + if (items.length === 0) return; + const limit = Math.max(1, concurrency); + for (let index = 0; index < items.length; index += limit) { + await Promise.all(items.slice(index, index + limit).map(worker)); + } +} + async function processAttachmentUpload( request: Request, env: Env, @@ -381,10 +393,9 @@ export async function deleteAllAttachmentsForCipher( ): Promise { const storage = new StorageService(env.DB); const attachments = await storage.getAttachmentsByCipher(cipherId); - - for (const attachment of attachments) { + await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => { const path = getAttachmentObjectKey(cipherId, attachment.id); await deleteBlobObject(env, path); await storage.deleteAttachment(attachment.id); - } + }); } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index fec3e46..ff3c63b 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -178,10 +178,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin : ciphers.filter(c => !c.deletedAt); } - const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); + const attachmentsByCipher = await storage.getAttachmentsByCipherIds( + filteredCiphers.map((cipher) => cipher.id) + ); - // Get attachments for all ciphers - const cipherResponses = []; + // Build responses only for the current page to keep pagination cheap. + const cipherResponses: CipherResponse[] = []; for (const cipher of filteredCiphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; cipherResponses.push(cipherToResponse(cipher, attachments)); diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 41ecd45..271535b 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -327,6 +327,8 @@ export async function handleToken(request: Request, env: Env): Promise const accessToken = await auth.generateAccessToken(user, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); const response: TokenResponse = { access_token: accessToken, @@ -336,8 +338,8 @@ export async function handleToken(request: Request, env: Env): Promise ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, - AccountKeys: buildAccountKeys(user), - accountKeys: buildAccountKeys(user), + AccountKeys: accountKeys, + accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, @@ -350,8 +352,8 @@ export async function handleToken(request: Request, env: Env): Promise ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, - UserDecryptionOptions: buildUserDecryptionOptions(user), - userDecryptionOptions: buildUserDecryptionOptions(user), + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); @@ -449,6 +451,8 @@ export async function handleToken(request: Request, env: Env): Promise const { accessToken, user, device } = result; const newRefreshToken = await auth.generateRefreshToken(user.id, device); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); const response: TokenResponse = { access_token: accessToken, @@ -457,8 +461,8 @@ export async function handleToken(request: Request, env: Env): Promise ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), Key: user.key, PrivateKey: user.privateKey, - AccountKeys: buildAccountKeys(user), - accountKeys: buildAccountKeys(user), + AccountKeys: accountKeys, + accountKeys: accountKeys, Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, @@ -471,8 +475,8 @@ export async function handleToken(request: Request, env: Env): Promise ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, - UserDecryptionOptions: buildUserDecryptionOptions(user), - userDecryptionOptions: buildUserDecryptionOptions(user), + UserDecryptionOptions: userDecryptionOptions, + userDecryptionOptions: userDecryptionOptions, }; const baseResponse = jsonResponse(response); diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index 337ce0e..ced00e4 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string) sends = await storage.getAllSends(userId); } + const sendResponses = sends.map(sendToResponse); return jsonResponse({ - data: sends.map(sendToResponse), + data: sendResponses, object: 'list', continuationToken, }); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index eec030b..e7b1d31 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -10,87 +10,23 @@ import { buildUserDecryptionOptions, } from '../utils/user-decryption'; -interface SyncCacheEntry { - userId: string; - revisionDate: string; - body: string; - expiresAt: number; - bytes: number; +function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request { + const url = new URL(request.url); + const cacheUrl = new URL( + `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`, + url.origin + ); + return new Request(cacheUrl.toString(), { method: 'GET' }); } -const syncResponseCache = new Map(); -let syncResponseCacheTotalBytes = 0; -const textEncoder = new TextEncoder(); - -function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string { - return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`; -} - -function readSyncCache(key: string): string | null { - const hit = syncResponseCache.get(key); +async function readSyncCache(cacheRequest: Request): Promise { + const hit = await caches.default.match(cacheRequest); if (!hit) return null; - if (hit.expiresAt <= Date.now()) { - deleteSyncCacheEntry(key, hit); - return null; - } - return hit.body; + return new Response(hit.body, hit); } -function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void { - const existing = entry ?? syncResponseCache.get(key); - if (!existing) return; - syncResponseCache.delete(key); - syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes); -} - -function pruneExpiredSyncCache(nowMs: number = Date.now()): void { - for (const [key, entry] of syncResponseCache.entries()) { - if (entry.expiresAt <= nowMs) { - deleteSyncCacheEntry(key, entry); - } - } -} - -function pruneStaleUserSyncCache(userId: string, revisionDate: string): void { - for (const [key, entry] of syncResponseCache.entries()) { - if (entry.userId === userId && entry.revisionDate !== revisionDate) { - deleteSyncCacheEntry(key, entry); - } - } -} - -function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void { - const nowMs = Date.now(); - pruneExpiredSyncCache(nowMs); - pruneStaleUserSyncCache(userId, revisionDate); - - const bodyBytes = textEncoder.encode(body).byteLength; - if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) { - return; - } - - const existing = syncResponseCache.get(key); - if (existing) { - deleteSyncCacheEntry(key, existing); - } - - while ( - syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries || - syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes - ) { - const oldestKey = syncResponseCache.keys().next().value as string | undefined; - if (!oldestKey) break; - deleteSyncCacheEntry(oldestKey); - } - - syncResponseCache.set(key, { - userId, - revisionDate, - body, - expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs, - bytes: bodyBytes, - }); - syncResponseCacheTotalBytes += bodyBytes; +async function writeSyncCache(cacheRequest: Request, response: Response): Promise { + await caches.default.put(cacheRequest, response.clone()); } // GET /api/sync @@ -99,28 +35,28 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const url = new URL(request.url); const excludeDomainsParam = url.searchParams.get('excludeDomains'); const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); - + const user = await storage.getUserById(userId); if (!user) { return errorResponse('User not found', 404); } const revisionDate = await storage.getRevisionDate(userId); - const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains); - const cachedBody = readSyncCache(cacheKey); - if (cachedBody) { - return new Response(cachedBody, { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains); + const cachedResponse = await readSyncCache(cacheRequest); + if (cachedResponse) { + return cachedResponse; } - const ciphers = await storage.getAllCiphers(userId); - const folders = await storage.getAllFolders(userId); - const sends = await storage.getAllSends(userId); - const attachmentsByCipher = await storage.getAttachmentsByUserId(userId); + const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([ + storage.getAllCiphers(userId), + storage.getAllFolders(userId), + storage.getAllSends(userId), + storage.getAttachmentsByUserId(userId), + ]); + const accountKeys = buildAccountKeys(user); + const userDecryptionOptions = buildUserDecryptionOptions(user); - // Build profile response const profile: ProfileResponse = { id: user.id, name: user.name, @@ -134,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr twoFactorEnabled: !!user.totpSecret, key: user.key, privateKey: user.privateKey, - accountKeys: buildAccountKeys(user), + accountKeys, securityStamp: user.securityStamp || user.id, organizations: [], providers: [], @@ -146,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr object: 'profile', }; - // Build cipher responses with attachments const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { - const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments)); + cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [])); } - // Build folder responses - const folderResponses: FolderResponse[] = folders.map(folder => ({ - id: folder.id, - name: folder.name, - revisionDate: folder.updatedAt, - object: 'folder', - })); + const folderResponses: FolderResponse[] = []; + for (const folder of folders) { + folderResponses.push({ + id: folder.id, + name: folder.name, + revisionDate: folder.updatedAt, + object: 'folder', + }); + } + const sendResponses = sends.map(sendToResponse); const syncResponse: SyncResponse = { - profile: profile, + profile, folders: folderResponses, collections: [], ciphers: cipherResponses, @@ -174,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr object: 'domains', }, policies: [], - sends: sends.map(sendToResponse), + sends: sendResponses, UserDecryption: { - MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock, + MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock, TrustedDeviceOption: null, KeyConnectorOption: null, Object: 'userDecryption', }, - // PascalCase for desktop/browser clients - UserDecryptionOptions: buildUserDecryptionOptions(user), - // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) + UserDecryptionOptions: userDecryptionOptions, userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'], object: 'sync', }; - const body = JSON.stringify(syncResponse); - writeSyncCache(userId, revisionDate, cacheKey, body); - - return new Response(body, { + const response = new Response(JSON.stringify(syncResponse), { status: 200, - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`, + }, }); + await writeSyncCache(cacheRequest, response); + return response; } diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 7b1d947..b29e9b1 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_at)', 'CREATE TABLE IF NOT EXISTS folders (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + @@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)', + 'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)', 'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2', 'ALTER TABLE sends ADD COLUMN emails TEXT', diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts index 65196e0..cc7d02d 100644 --- a/webapp/src/lib/api/send.ts +++ b/webapp/src/lib/api/send.ts @@ -1,6 +1,7 @@ import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto'; import type { Send, SendDraft, SessionState } from '../types'; import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared'; +import { loadVaultSyncSnapshot } from './vault-sync'; function toIsoDateFromDays(value: string, required: boolean): string | null { const raw = String(value || '').trim(); @@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null { } export async function getSends(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/sends'); - if (!resp.ok) throw new Error('Failed to load sends'); - const body = await parseJson<{ object: 'list'; data: Send[] }>(resp); - return body?.data || []; + const body = await loadVaultSyncSnapshot(authedFetch); + return body.sends || []; } export async function createSend( diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts new file mode 100644 index 0000000..4dc2474 --- /dev/null +++ b/webapp/src/lib/api/vault-sync.ts @@ -0,0 +1,31 @@ +import type { Cipher, Folder, Send } from '../types'; +import { parseJson, type AuthedFetch } from './shared'; + +interface VaultSyncResponse { + ciphers?: Cipher[]; + folders?: Folder[]; + sends?: Send[]; +} + +const pendingSyncRequests = new WeakMap>(); + +export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise { + const existing = pendingSyncRequests.get(authedFetch); + if (existing) return existing; + + const request = (async () => { + const resp = await authedFetch('/api/sync'); + if (!resp.ok) throw new Error('Failed to load vault'); + const body = await parseJson(resp); + return body || {}; + })(); + + pendingSyncRequests.set(authedFetch, request); + try { + return await request; + } finally { + if (pendingSyncRequests.get(authedFetch) === request) { + pendingSyncRequests.delete(authedFetch); + } + } +} diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 56b80b3..ad06dea 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -2,7 +2,6 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, enc import type { Cipher, Folder, - ListResponse, SessionState, VaultDraft, VaultDraftField, @@ -16,12 +15,11 @@ import { type AuthedFetch, } from './shared'; import { readResponseBytesWithProgress } from '../download'; +import { loadVaultSyncSnapshot } from './vault-sync'; export async function getFolders(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/folders'); - if (!resp.ok) throw new Error('Failed to load folders'); - const body = await parseJson>(resp); - return body?.data || []; + const body = await loadVaultSyncSnapshot(authedFetch); + return body.folders || []; } export async function createFolder( @@ -93,10 +91,8 @@ export async function updateFolder( } export async function getCiphers(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/ciphers?deleted=true'); - if (!resp.ok) throw new Error('Failed to load ciphers'); - const body = await parseJson>(resp); - return body?.data || []; + const body = await loadVaultSyncSnapshot(authedFetch); + return body.ciphers || []; } export interface CiphersImportPayload { From 2230f75d8a389b719933915059b8ff9ba023de90 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 9 Apr 2026 23:27:40 +0800 Subject: [PATCH 5/6] feat: add loading state management for TOTP and import/export operations --- webapp/src/App.tsx | 27 ++++++- webapp/src/components/AppGlobalOverlays.tsx | 8 +- webapp/src/components/BackupCenterPage.tsx | 12 +++ webapp/src/components/ImportPage.tsx | 9 +++ webapp/src/components/VaultPage.tsx | 1 + webapp/src/components/vault/VaultDialogs.tsx | 82 ++++++++++++++++++-- 6 files changed, 131 insertions(+), 8 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 5e89afe..0e83f62 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -172,9 +172,11 @@ export default function App() { const [pendingTotp, setPendingTotp] = useState(null); const [totpCode, setTotpCode] = useState(''); const [rememberDevice, setRememberDevice] = useState(true); + const [totpSubmitting, setTotpSubmitting] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpPassword, setDisableTotpPassword] = useState(''); + const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [themePreference, setThemePreference] = useState(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); @@ -433,16 +435,20 @@ export default function App() { } async function handleTotpVerify() { + if (totpSubmitting) return; if (!pendingTotp) return; if (!totpCode.trim()) { pushToast('error', t('txt_please_input_totp_code')); return; } + setTotpSubmitting(true); try { const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); await finalizeLogin(login); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); + } finally { + setTotpSubmitting(false); } } @@ -631,11 +637,13 @@ export default function App() { onConfirmTotp={() => {}} onCancelTotp={() => {}} onUseRecoveryCode={() => {}} + totpSubmitting={false} disableTotpOpen={false} disableTotpPassword="" onDisableTotpPasswordChange={() => {}} onConfirmDisableTotp={() => {}} onCancelDisableTotp={() => {}} + disableTotpSubmitting={false} /> ); } @@ -1288,21 +1296,25 @@ export default function App() { onRememberDeviceChange={setRememberDevice} onConfirmTotp={() => void handleTotpVerify()} onCancelTotp={() => { + if (totpSubmitting) return; setPendingTotp(null); setTotpCode(''); setRememberDevice(true); }} onUseRecoveryCode={() => { + if (totpSubmitting) return; setPendingTotp(null); setTotpCode(''); setRememberDevice(true); navigate('/recover-2fa'); }} + totpSubmitting={totpSubmitting} disableTotpOpen={false} disableTotpPassword="" onDisableTotpPasswordChange={() => {}} onConfirmDisableTotp={() => {}} onCancelDisableTotp={() => {}} + disableTotpSubmitting={false} /> ); @@ -1341,14 +1353,27 @@ export default function App() { onConfirmTotp={() => {}} onCancelTotp={() => {}} onUseRecoveryCode={() => {}} + totpSubmitting={false} disableTotpOpen={disableTotpOpen} disableTotpPassword={disableTotpPassword} onDisableTotpPasswordChange={setDisableTotpPassword} - onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()} + onConfirmDisableTotp={() => { + if (disableTotpSubmitting) return; + void (async () => { + setDisableTotpSubmitting(true); + try { + await accountSecurityActions.disableTotp(); + } finally { + setDisableTotpSubmitting(false); + } + })(); + }} onCancelDisableTotp={() => { + if (disableTotpSubmitting) return; setDisableTotpOpen(false); setDisableTotpPassword(''); }} + disableTotpSubmitting={disableTotpSubmitting} /> ); diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 8f8940e..e6f8ab8 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -27,11 +27,13 @@ interface AppGlobalOverlaysProps { onConfirmTotp: () => void; onCancelTotp: () => void; onUseRecoveryCode: () => void; + totpSubmitting: boolean; disableTotpOpen: boolean; disableTotpPassword: string; onDisableTotpPasswordChange: (value: string) => void; onConfirmDisableTotp: () => void; onCancelDisableTotp: () => void; + disableTotpSubmitting: boolean; } export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { @@ -57,12 +59,14 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { confirmText={t('txt_verify')} cancelText={t('txt_cancel')} showIcon={false} + confirmDisabled={props.totpSubmitting} + cancelDisabled={props.totpSubmitting} onConfirm={props.onConfirmTotp} onCancel={props.onCancelTotp} afterActions={(
-
@@ -86,6 +90,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { cancelText={t('txt_cancel')} danger showIcon={false} + confirmDisabled={props.disableTotpSubmitting} + cancelDisabled={props.disableTotpSubmitting} onConfirm={props.onConfirmDisableTotp} onCancel={props.onCancelDisableTotp} > diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index e57e17b..3c9f92c 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -528,6 +528,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { allowChecksumMismatch: boolean = false, knownIntegrity?: BackupFileIntegrityCheckResult ) { + if (importing) return; if (!selectedFile) { const message = t('txt_backup_file_required'); setLocalError(message); @@ -654,6 +655,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } async function handleDeleteRemote(path: string) { + if (deletingRemotePath) return; if (!savedSelectedDestination) return; setDeletingRemotePath(path); setLocalError(''); @@ -723,6 +725,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { allowChecksumMismatch: boolean = false, knownIntegrity?: BackupFileIntegrityCheckResult ) { + if (restoringRemotePath) return; if (!savedSelectedDestination) return; setConfirmRemoteReplaceOpen(false); setConfirmIntegrityWarningOpen(false); @@ -896,9 +899,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')} confirmText={t('txt_backup_import')} cancelText={t('txt_cancel')} + confirmDisabled={importing} + cancelDisabled={importing} danger onConfirm={() => void runLocalRestore(false)} onCancel={() => { + if (importing) return; setConfirmLocalRestoreOpen(false); resetSelectedFile(); resetPendingIntegrityWarning(); @@ -959,6 +965,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { variant="warning" confirmText={t('txt_backup_restore_checksum_warning_confirm')} cancelText={t('txt_cancel')} + confirmDisabled={importing || !!restoringRemotePath} + cancelDisabled={importing || !!restoringRemotePath} danger onConfirm={() => { if (!pendingRestoreIntegrity) return; @@ -984,6 +992,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} + confirmDisabled={!!deletingRemotePath} + cancelDisabled={!!deletingRemotePath} danger onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)} onCancel={() => { @@ -1001,6 +1011,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { })} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} + confirmDisabled={savingSettings} + cancelDisabled={savingSettings} danger onConfirm={() => void handleDeleteDestination()} onCancel={() => { diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index 173ce12..2c66bf6 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -468,6 +468,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys } async function handlePasswordImportConfirm() { + if (isPasswordSubmitting) return; if (!pendingPasswordImport) return; setIsPasswordSubmitting(true); try { @@ -486,6 +487,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys } async function handleZipPasswordImportConfirm() { + if (isZipPasswordSubmitting) return; if (!pendingZipFile) return; setIsZipPasswordSubmitting(true); try { @@ -558,6 +560,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys } async function handleExportConfirmPassword() { + if (isExporting) return; const masterPassword = String(exportAuthPassword || '').trim(); if (!masterPassword) { onNotify('error', t('txt_master_password_is_required')); @@ -736,6 +739,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys confirmText={isExporting ? t('txt_loading') : t('txt_verify')} cancelText={t('txt_cancel')} showIcon={false} + confirmDisabled={isExporting} + cancelDisabled={isExporting} onConfirm={() => void handleExportConfirmPassword()} onCancel={() => { if (isExporting) return; @@ -761,6 +766,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')} cancelText={t('txt_cancel')} showIcon={false} + confirmDisabled={isPasswordSubmitting} + cancelDisabled={isPasswordSubmitting} onConfirm={() => void handlePasswordImportConfirm()} onCancel={() => { if (isPasswordSubmitting) return; @@ -787,6 +794,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')} cancelText={t('txt_cancel')} showIcon={false} + confirmDisabled={isZipPasswordSubmitting} + cancelDisabled={isZipPasswordSubmitting} onConfirm={() => void handleZipPasswordImportConfirm()} onCancel={() => { if (isZipPasswordSubmitting) return; diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 3ec57dd..0cb6e91 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1009,6 +1009,7 @@ function folderName(id: string | null | undefined): string {
@@ -118,11 +121,22 @@ export default function VaultDialogs(props: VaultDialogsProps) { message={t('txt_archive_selected_items_message', { count: props.selectedCount })} confirmText={t('txt_archive')} cancelText={t('txt_cancel')} + confirmDisabled={props.busy} + cancelDisabled={props.busy} onConfirm={props.onConfirmBulkArchive} onCancel={props.onCancelBulkArchive} /> - + - + - +