feat: enhance sync functionality by adding excludeSends option and refactor related API calls

This commit is contained in:
shuaiplus
2026-04-27 01:41:56 +08:00
parent d589b15123
commit 7ab836d0f3
8 changed files with 139 additions and 102 deletions
+6 -4
View File
@@ -10,10 +10,10 @@ import {
buildUserDecryptionOptions, buildUserDecryptionOptions,
} from '../utils/user-decryption'; } 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 url = new URL(request.url);
const cacheUrl = new 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 url.origin
); );
return new Request(cacheUrl.toString(), { method: 'GET' }); 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 url = new URL(request.url);
const excludeDomainsParam = url.searchParams.get('excludeDomains'); const excludeDomainsParam = url.searchParams.get('excludeDomains');
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); 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); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -42,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
} }
const revisionDate = await storage.getRevisionDate(userId); 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); const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) { if (cachedResponse) {
return 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([ const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
storage.getAllCiphers(userId), storage.getAllCiphers(userId),
storage.getAllFolders(userId), storage.getAllFolders(userId),
storage.getAllSends(userId), excludeSends ? Promise.resolve([]) : storage.getAllSends(userId),
storage.getAttachmentsByUserId(userId), storage.getAttachmentsByUserId(userId),
]); ]);
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
+55 -16
View File
@@ -169,6 +169,7 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]); const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]); const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]); const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session); const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set()); const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {}); const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
@@ -740,37 +741,38 @@ export default function App() {
const sendsQuery = useQuery({ const sendsQuery = useQuery({
queryKey: ['sends', session?.accessToken], queryKey: ['sends', session?.accessToken],
queryFn: () => getSends(authedFetch), queryFn: () => getSends(authedFetch),
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'),
}); });
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['admin-users', session?.accessToken], queryKey: ['admin-users', session?.accessToken],
queryFn: () => listAdminUsers(authedFetch), queryFn: () => listAdminUsers(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin', enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
}); });
const invitesQuery = useQuery({ const invitesQuery = useQuery({
queryKey: ['admin-invites', session?.accessToken], queryKey: ['admin-invites', session?.accessToken],
queryFn: () => listAdminInvites(authedFetch), queryFn: () => listAdminInvites(authedFetch),
enabled: phase === 'app' && profile?.role === 'admin', enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
}); });
const totpStatusQuery = useQuery({ const totpStatusQuery = useQuery({
queryKey: ['totp-status', session?.accessToken], queryKey: ['totp-status', session?.accessToken],
queryFn: () => getTotpStatus(authedFetch), queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken, enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
}); });
const authorizedDevicesQuery = useQuery({ const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', session?.accessToken], queryKey: ['authorized-devices', session?.accessToken],
queryFn: () => getAuthorizedDevices(authedFetch), queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken, enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
}); });
useEffect(() => { useEffect(() => {
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
if (!vaultInitialDecryptDone) return;
if (!profile?.role || profile.role !== 'admin') return; if (!profile?.role || profile.role !== 'admin') return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void silentlyRepairBackupSettingsIfNeeded(session, profile);
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
@@ -782,9 +784,10 @@ export default function App() {
setDecryptedFolders([]); setDecryptedFolders([]);
setDecryptedCiphers([]); setDecryptedCiphers([]);
setDecryptedSends([]); setDecryptedSends([]);
setVaultInitialDecryptDone(false);
return; return;
} }
if (!foldersQuery.data || !ciphersQuery.data || !sendsQuery.data) return; if (!foldersQuery.data || !ciphersQuery.data) return;
let active = true; let active = true;
(async () => { (async () => {
@@ -982,8 +985,46 @@ export default function App() {
}) })
); );
const sends = await Promise.all( if (!active) return;
sendsQuery.data.map(async (send) => { 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<string> => {
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 }; const nextSend: Send = { ...send };
try { try {
if (send.key) { if (send.key) {
@@ -1011,12 +1052,9 @@ export default function App() {
nextSend.decName = t('txt_decrypt_failed'); nextSend.decName = t('txt_decrypt_failed');
} }
return nextSend; return nextSend;
}) }));
);
if (!active) return; if (!active) return;
setDecryptedFolders(folders);
setDecryptedCiphers(ciphers);
setDecryptedSends(sends); setDecryptedSends(sends);
} catch (error) { } catch (error) {
if (!active) return; if (!active) return;
@@ -1027,7 +1065,7 @@ export default function App() {
return () => { return () => {
active = false; active = false;
}; };
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); }, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
useEffect(() => { useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return; if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
@@ -1061,7 +1099,7 @@ export default function App() {
silentRefreshVaultRef.current = refreshVaultSilently; silentRefreshVaultRef.current = refreshVaultSilently;
useEffect(() => { 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 disposed = false;
let socket: WebSocket | null = null; 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({ const vaultSendActions = useVaultSendActions({
authedFetch, authedFetch,
@@ -1227,6 +1265,7 @@ export default function App() {
}); });
refreshAuthorizedDevicesRef.current = async () => { refreshAuthorizedDevicesRef.current = async () => {
if (!vaultInitialDecryptDone) return;
await authorizedDevicesQuery.refetch(); await authorizedDevicesQuery.refetch();
}; };
+1 -3
View File
@@ -3,6 +3,7 @@ import { useEffect } from 'preact/hooks';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import VaultPage from '@/components/VaultPage';
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -11,7 +12,6 @@ import type { ExportRequest } from '@/lib/export-formats';
const SendsPage = lazy(() => import('@/components/SendsPage')); const SendsPage = lazy(() => import('@/components/SendsPage'));
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
const VaultPage = lazy(() => import('@/components/VaultPage'));
const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage')); const AdminPage = lazy(() => import('@/components/AdminPage'));
@@ -181,7 +181,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Suspense> </Suspense>
</Route> </Route>
<Route path="/vault"> <Route path="/vault">
<Suspense fallback={<RouteContentFallback />}>
<VaultPage <VaultPage
ciphers={props.decryptedCiphers} ciphers={props.decryptedCiphers}
folders={props.decryptedFolders} folders={props.decryptedFolders}
@@ -212,7 +211,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
attachmentUploadPercent={props.attachmentUploadPercent} attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey} mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/> />
</Suspense>
</Route> </Route>
<Route path={props.settingsAccountRoute}> <Route path={props.settingsAccountRoute}>
{props.profile && ( {props.profile && (
@@ -203,10 +203,9 @@ export default function VaultListPanel(props: VaultListPanelProps) {
}) })
); );
const sortableCiphers = props.canReorder ? props.filteredCiphers : props.visibleCiphers; const sortableItems = props.filteredCiphers.map((cipher) => cipher.id);
const virtualPadTop = props.canReorder ? 0 : props.virtualRange.padTop; const renderedCiphers = props.visibleCiphers;
const virtualPadBottom = props.canReorder ? 0 : props.virtualRange.padBottom; const activeDragCipher = activeDragId ? props.filteredCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
const activeDragCipher = activeDragId ? sortableCiphers.find((cipher) => cipher.id === activeDragId) || null : null;
const handleDragStart = (event: DragStartEvent) => { const handleDragStart = (event: DragStartEvent) => {
setActiveDragId(String(event.active.id)); setActiveDragId(String(event.active.id));
@@ -357,10 +356,10 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!props.filteredCiphers.length && ( {!!props.filteredCiphers.length && (
<div style={{ paddingTop: `${virtualPadTop}px`, paddingBottom: `${virtualPadBottom}px` }}> <div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel}>
<SortableContext items={sortableCiphers.map((cipher) => cipher.id)} strategy={verticalListSortingStrategy}> <SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
{sortableCiphers.map((cipher) => ( {renderedCiphers.map((cipher) => (
<SortableCipherListItem <SortableCipherListItem
key={cipher.id} key={cipher.id}
cipher={cipher} cipher={cipher}
+4 -3
View File
@@ -1,7 +1,6 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto'; import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
import type { Send, SendDraft, SessionState } from '../types'; import type { Send, SendDraft, SessionState } from '../types';
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared'; import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
import { loadVaultSyncSnapshot } from './vault-sync';
function toIsoDateFromDays(value: string, required: boolean): string | null { function toIsoDateFromDays(value: string, required: boolean): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
@@ -62,8 +61,10 @@ function parseMaxAccessCountRaw(value: string): number | null {
} }
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> { export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const resp = await authedFetch('/api/sends');
return body.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( export async function createSend(
+7 -7
View File
@@ -7,14 +7,14 @@ interface VaultSyncResponse {
sends?: Send[]; sends?: Send[];
} }
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>(); const pendingVaultCoreRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> { export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
const existing = pendingSyncRequests.get(authedFetch); const existing = pendingVaultCoreRequests.get(authedFetch);
if (existing) return existing; if (existing) return existing;
const request = (async () => { const request = (async () => {
const resp = await authedFetch('/api/sync', { const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
@@ -26,12 +26,12 @@ export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<V
return body || {}; return body || {};
})(); })();
pendingSyncRequests.set(authedFetch, request); pendingVaultCoreRequests.set(authedFetch, request);
try { try {
return await request; return await request;
} finally { } finally {
if (pendingSyncRequests.get(authedFetch) === request) { if (pendingVaultCoreRequests.get(authedFetch) === request) {
pendingSyncRequests.delete(authedFetch); pendingVaultCoreRequests.delete(authedFetch);
} }
} }
} }
+3 -3
View File
@@ -17,10 +17,10 @@ import {
type AuthedFetch, type AuthedFetch,
} from './shared'; } from './shared';
import { readResponseBytesWithProgress } from '../download'; import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync'; import { loadVaultCoreSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> { export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const body = await loadVaultCoreSyncSnapshot(authedFetch);
return body.folders || []; return body.folders || [];
} }
@@ -93,7 +93,7 @@ export async function updateFolder(
} }
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> { export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
const body = await loadVaultSyncSnapshot(authedFetch); const body = await loadVaultCoreSyncSnapshot(authedFetch);
return body.ciphers || []; return body.ciphers || [];
} }
+1 -3
View File
@@ -42,10 +42,8 @@ export default defineConfig({
normalized.includes('/src/components/ImportPage.tsx') || normalized.includes('/src/components/ImportPage.tsx') ||
normalized.includes('/src/lib/import-') || normalized.includes('/src/lib/import-') ||
normalized.includes('/src/lib/export-formats.ts') || normalized.includes('/src/lib/export-formats.ts') ||
normalized.includes('/src/components/VaultPage.tsx') ||
normalized.includes('/src/components/SendsPage.tsx') || normalized.includes('/src/components/SendsPage.tsx') ||
normalized.includes('/src/components/TotpCodesPage.tsx') || normalized.includes('/src/components/TotpCodesPage.tsx')
normalized.includes('/src/components/vault/')
) { ) {
return 'workspace-suite'; return 'workspace-suite';
} }