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
+81 -42
View File
@@ -169,6 +169,7 @@ export default function App() {
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
const silentRefreshVaultRef = useRef<() => Promise<void>>(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<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 };
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();
};
+31 -33
View File
@@ -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) {
</Suspense>
</Route>
<Route path="/vault">
<Suspense fallback={<RouteContentFallback />}>
<VaultPage
ciphers={props.decryptedCiphers}
folders={props.decryptedFolders}
loading={props.ciphersLoading || props.foldersLoading}
emailForReprompt={props.profile?.email || props.session?.email || ''}
onRefresh={props.onRefreshVault}
onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify}
onCreateFolder={props.onCreateFolder}
onRenameFolder={props.onRenameFolder}
onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/>
</Suspense>
<VaultPage
ciphers={props.decryptedCiphers}
folders={props.decryptedFolders}
loading={props.ciphersLoading || props.foldersLoading}
emailForReprompt={props.profile?.email || props.session?.email || ''}
onRefresh={props.onRefreshVault}
onCreate={props.onCreateVaultItem}
onUpdate={props.onUpdateVaultItem}
onDelete={props.onDeleteVaultItem}
onArchive={props.onArchiveVaultItem}
onUnarchive={props.onUnarchiveVaultItem}
onBulkDelete={props.onBulkDeleteVaultItems}
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
onBulkRestore={props.onBulkRestoreVaultItems}
onBulkArchive={props.onBulkArchiveVaultItems}
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
onBulkMove={props.onBulkMoveVaultItems}
onVerifyMasterPassword={props.onVerifyMasterPassword}
onNotify={props.onNotify}
onCreateFolder={props.onCreateFolder}
onRenameFolder={props.onRenameFolder}
onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
mobileSidebarToggleKey={props.mobileSidebarToggleKey}
/>
</Route>
<Route path={props.settingsAccountRoute}>
{props.profile && (
@@ -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) {
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
{!!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}>
<SortableContext items={sortableCiphers.map((cipher) => cipher.id)} strategy={verticalListSortingStrategy}>
{sortableCiphers.map((cipher) => (
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
{renderedCiphers.map((cipher) => (
<SortableCipherListItem
key={cipher.id}
cipher={cipher}
+4 -3
View File
@@ -1,7 +1,6 @@
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();
@@ -62,8 +61,10 @@ function parseMaxAccessCountRaw(value: string): number | null {
}
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
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(
+7 -7
View File
@@ -7,14 +7,14 @@ interface VaultSyncResponse {
sends?: Send[];
}
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
const pendingVaultCoreRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
const existing = pendingSyncRequests.get(authedFetch);
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
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<V
return body || {};
})();
pendingSyncRequests.set(authedFetch, request);
pendingVaultCoreRequests.set(authedFetch, request);
try {
return await request;
} finally {
if (pendingSyncRequests.get(authedFetch) === request) {
pendingSyncRequests.delete(authedFetch);
if (pendingVaultCoreRequests.get(authedFetch) === request) {
pendingVaultCoreRequests.delete(authedFetch);
}
}
}
+3 -3
View File
@@ -17,10 +17,10 @@ import {
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { loadVaultSyncSnapshot } from './vault-sync';
import { loadVaultCoreSyncSnapshot } from './vault-sync';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
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<Cipher[]> {
const body = await loadVaultSyncSnapshot(authedFetch);
const body = await loadVaultCoreSyncSnapshot(authedFetch);
return body.ciphers || [];
}