From 7ab836d0f358676633d3ecab08b1f9a60232f41e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 27 Apr 2026 01:41:56 +0800 Subject: [PATCH] feat: enhance sync functionality by adding excludeSends option and refactor related API calls --- src/handlers/sync.ts | 10 +- webapp/src/App.tsx | 123 ++++++++++++------ webapp/src/components/AppMainRoutes.tsx | 64 +++++---- .../src/components/vault/VaultListPanel.tsx | 13 +- webapp/src/lib/api/send.ts | 7 +- webapp/src/lib/api/vault-sync.ts | 14 +- webapp/src/lib/api/vault.ts | 6 +- webapp/vite.config.ts | 4 +- 8 files changed, 139 insertions(+), 102 deletions(-) diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 6720a21..f64fd4e 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -10,10 +10,10 @@ import { buildUserDecryptionOptions, } from '../utils/user-decryption'; -function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request { +function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request { const url = new URL(request.url); const cacheUrl = new URL( - `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`, + `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`, url.origin ); return new Request(cacheUrl.toString(), { method: 'GET' }); @@ -35,6 +35,8 @@ 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 excludeSendsParam = url.searchParams.get('excludeSends'); + const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam); const user = await storage.getUserById(userId); if (!user) { @@ -42,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr } const revisionDate = await storage.getRevisionDate(userId); - const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains); + const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends); const cachedResponse = await readSyncCache(cacheRequest); if (cachedResponse) { return cachedResponse; @@ -51,7 +53,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([ storage.getAllCiphers(userId), storage.getAllFolders(userId), - storage.getAllSends(userId), + excludeSends ? Promise.resolve([]) : storage.getAllSends(userId), storage.getAttachmentsByUserId(userId), ]); const accountKeys = buildAccountKeys(user); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6465ea5..654e66c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -169,6 +169,7 @@ export default function App() { const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); + const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); @@ -740,37 +741,38 @@ export default function App() { const sendsQuery = useQuery({ queryKey: ['sends', session?.accessToken], queryFn: () => getSends(authedFetch), - enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'), }); const usersQuery = useQuery({ queryKey: ['admin-users', session?.accessToken], queryFn: () => listAdminUsers(authedFetch), - enabled: phase === 'app' && profile?.role === 'admin', + enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, }); const invitesQuery = useQuery({ queryKey: ['admin-invites', session?.accessToken], queryFn: () => listAdminInvites(authedFetch), - enabled: phase === 'app' && profile?.role === 'admin', + enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, }); const totpStatusQuery = useQuery({ queryKey: ['totp-status', session?.accessToken], queryFn: () => getTotpStatus(authedFetch), - enabled: phase === 'app' && !!session?.accessToken, + enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, }); const authorizedDevicesQuery = useQuery({ queryKey: ['authorized-devices', session?.accessToken], queryFn: () => getAuthorizedDevices(authedFetch), - enabled: phase === 'app' && !!session?.accessToken, + enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, }); useEffect(() => { if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; + if (!vaultInitialDecryptDone) return; if (!profile?.role || profile.role !== 'admin') return; if (repairAttemptRef.current === session.accessToken) return; repairAttemptRef.current = session.accessToken; void silentlyRepairBackupSettingsIfNeeded(session, profile); - }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]); + }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); useEffect(() => { if (session?.accessToken) return; @@ -782,9 +784,10 @@ export default function App() { setDecryptedFolders([]); setDecryptedCiphers([]); setDecryptedSends([]); + setVaultInitialDecryptDone(false); return; } - if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return; + if (!foldersQuery.data || !ciphersQuery.data) return; let active = true; (async () => { @@ -982,41 +985,76 @@ export default function App() { }) ); - const sends = await Promise.all( - sendsQuery.data.map(async (send) => { - const nextSend: Send = { ...send }; - try { - if (send.key) { - const sendKeyRaw = await decryptBw(send.key, encKey, macKey); - const derived = await deriveSendKeyParts(sendKeyRaw); - nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); - nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); - nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); - if (send.file?.fileName) { - const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); - nextSend.file = { - ...(send.file || {}), - fileName: decFileName || send.file.fileName, - }; - } - const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); - nextSend.decShareKey = shareKey; - nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); - } else { - nextSend.decName = ''; - nextSend.decNotes = ''; - nextSend.decText = ''; - } - } catch { - nextSend.decName = t('txt_decrypt_failed'); - } - return nextSend; - }) - ); - if (!active) return; setDecryptedFolders(folders); setDecryptedCiphers(ciphers); + setVaultInitialDecryptDone(true); + } catch (error) { + if (!active) return; + pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); + } + })(); + + return () => { + active = false; + }; + }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]); + + useEffect(() => { + if (!session?.symEncKey || !session?.symMacKey) { + setDecryptedSends([]); + return; + } + if (!sendsQuery.data) return; + + let active = true; + (async () => { + try { + const encKey = base64ToBytes(session.symEncKey!); + const macKey = base64ToBytes(session.symMacKey!); + const decryptField = async ( + value: string | null | undefined, + fieldEnc: Uint8Array = encKey, + fieldMac: Uint8Array = macKey + ): Promise => { + if (!value || typeof value !== 'string') return ''; + try { + return await decryptStr(value, fieldEnc, fieldMac); + } catch { + return value; + } + }; + const sends = await Promise.all(sendsQuery.data.map(async (send) => { + const nextSend: Send = { ...send }; + try { + if (send.key) { + const sendKeyRaw = await decryptBw(send.key, encKey, macKey); + const derived = await deriveSendKeyParts(sendKeyRaw); + nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); + nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); + nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); + if (send.file?.fileName) { + const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); + nextSend.file = { + ...(send.file || {}), + fileName: decFileName || send.file.fileName, + }; + } + const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); + nextSend.decShareKey = shareKey; + nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); + } else { + nextSend.decName = ''; + nextSend.decNotes = ''; + nextSend.decText = ''; + } + } catch { + nextSend.decName = t('txt_decrypt_failed'); + } + return nextSend; + })); + + if (!active) return; setDecryptedSends(sends); } catch (error) { if (!active) return; @@ -1027,7 +1065,7 @@ export default function App() { return () => { active = false; }; - }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); + }, [session?.symEncKey, session?.symMacKey, sendsQuery.data]); useEffect(() => { if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return; @@ -1061,7 +1099,7 @@ export default function App() { silentRefreshVaultRef.current = refreshVaultSilently; useEffect(() => { - if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; + if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return; let disposed = false; let socket: WebSocket | null = null; @@ -1187,7 +1225,7 @@ export default function App() { } } }; - }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]); + }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, vaultInitialDecryptDone]); const vaultSendActions = useVaultSendActions({ authedFetch, @@ -1227,6 +1265,7 @@ export default function App() { }); refreshAuthorizedDevicesRef.current = async () => { + if (!vaultInitialDecryptDone) return; await authorizedDevicesQuery.refetch(); }; diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 38e0271..eaa7eac 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks'; import { Link, Route, Switch } from 'wouter'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; +import VaultPage from '@/components/VaultPage'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { t } from '@/lib/i18n'; @@ -11,7 +12,6 @@ import type { ExportRequest } from '@/lib/export-formats'; const SendsPage = lazy(() => import('@/components/SendsPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); -const VaultPage = lazy(() => import('@/components/VaultPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const AdminPage = lazy(() => import('@/components/AdminPage')); @@ -181,38 +181,36 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { - }> - - + {props.profile && ( diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 96bf86f..cbd976c 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -203,10 +203,9 @@ export default function VaultListPanel(props: VaultListPanelProps) { }) ); - const sortableCiphers = props.canReorder ? props.filteredCiphers : props.visibleCiphers; - const virtualPadTop = props.canReorder ? 0 : props.virtualRange.padTop; - const virtualPadBottom = props.canReorder ? 0 : props.virtualRange.padBottom; - const activeDragCipher = activeDragId ? sortableCiphers.find((cipher) => cipher.id === activeDragId) || null : null; + const sortableItems = props.filteredCiphers.map((cipher) => cipher.id); + const renderedCiphers = props.visibleCiphers; + const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null; const handleDragStart = (event: DragStartEvent) => { setActiveDragId(String(event.active.id)); @@ -357,10 +356,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> {!!props.filteredCiphers.length && ( -
+
- cipher.id)} strategy={verticalListSortingStrategy}> - {sortableCiphers.map((cipher) => ( + + {renderedCiphers.map((cipher) => ( { - const body = await loadVaultSyncSnapshot(authedFetch); - return body.sends || []; + const resp = await authedFetch('/api/sends'); + if (!resp.ok) throw new Error('Failed to load sends'); + const body = await parseJson<{ data?: Send[] }>(resp); + return body?.data || []; } export async function createSend( diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index 722df98..9254cc2 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -7,14 +7,14 @@ interface VaultSyncResponse { sends?: Send[]; } -const pendingSyncRequests = new WeakMap>(); +const pendingVaultCoreRequests = new WeakMap>(); -export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise { - const existing = pendingSyncRequests.get(authedFetch); +export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promise { + const existing = pendingVaultCoreRequests.get(authedFetch); if (existing) return existing; const request = (async () => { - const resp = await authedFetch('/api/sync', { + const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', { cache: 'no-store', headers: { 'Cache-Control': 'no-cache', @@ -26,12 +26,12 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise { - const body = await loadVaultSyncSnapshot(authedFetch); + const body = await loadVaultCoreSyncSnapshot(authedFetch); return body.folders || []; } @@ -93,7 +93,7 @@ export async function updateFolder( } export async function getCiphers(authedFetch: AuthedFetch): Promise { - const body = await loadVaultSyncSnapshot(authedFetch); + const body = await loadVaultCoreSyncSnapshot(authedFetch); return body.ciphers || []; } diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 3519ee2..70830b0 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -42,10 +42,8 @@ export default defineConfig({ normalized.includes('/src/components/ImportPage.tsx') || normalized.includes('/src/lib/import-') || normalized.includes('/src/lib/export-formats.ts') || - normalized.includes('/src/components/VaultPage.tsx') || normalized.includes('/src/components/SendsPage.tsx') || - normalized.includes('/src/components/TotpCodesPage.tsx') || - normalized.includes('/src/components/vault/') + normalized.includes('/src/components/TotpCodesPage.tsx') ) { return 'workspace-suite'; }