/
nasya
/
SafeDrop
Обзор
Документация
Войти
/
nasya
/
SafeDrop
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
apps/web/src/lib/app-controller.tsx
750 строк
26 KB
nasya
Initial commit
24 май 2026, 16:12
24 май 2026, 16:12
16bc032
Код
Авторство
О чём код?
import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { AppShell, type AppPage, navigationItems } from "@/components/app-shell"; import { getCurrentIdentity } from "@/lib/api/auth-api"; import { clearInboxHistory, clearSentHistory, downloadEncryptedBlob, type FileRecord, getFileDetails, getPublicKeys, getPublicKeysByEmail, listInbox, listSent, markInboxRead, selectActivePublicKey, updateFileReadState, uploadEncryptedFile, } from "@/lib/api/file-api"; import { buildRecipientShareUrl, buildSecretShareUrl, createRecipientShare, createSecretShare, listActiveShares, revokeShare, type ShareRecord, } from "@/lib/api/share-api"; import { getVault, rotateVault, saveVault, type VaultRecord } from "@/lib/api/vault-api"; import { apiUrl, authUrl, oauthConfig } from "@/lib/app-config"; import { clearAccessToken, createAuthorizationRequest, exchangeAuthorizationCode, parseOAuthCallback, readAccessToken, } from "@/lib/auth/oauth"; import { createEncryptedVault, createSignedFileRequest, decryptFileBytes, encryptFileBytes, exportFileKeyToFragment, getEncryptionPrivateKey, type KeyVaultMaterial, rotateEncryptedVault, sha256, toBase64, unlockEncryptedVault, unwrapFileKey, verifyFileMetadataSignature, wrapFileKey, } from "@/lib/crypto/crypto"; import { forgetTrustedDeviceVault, rememberTrustedDeviceVault, restoreTrustedDeviceVault, } from "@/lib/crypto/device-vault"; import { savePlaintextFile } from "@/lib/download"; import { InboxPage } from "@/pages/inbox-page"; import { KeysPage } from "@/pages/keys-page"; import { Root } from "@/pages/root"; import { SendPage } from "@/pages/send-page"; import { SentPage } from "@/pages/sent-page"; import { WorkspacePage } from "@/pages/workspace-page"; import type { ShareMode, VaultStatus } from "@/types/dashboard"; function resolvePage(pathname: string): AppPage { if (pathname === "/links") { return "send"; } return navigationItems.find((item) => item.path === pathname)?.id ?? "workspace"; } function DashboardPage() { const [accessToken, setAccessToken] = useState<string | null>(() => readAccessToken()); const [activePage, setActivePage] = useState<AppPage>(() => resolvePage(window.location.pathname)); const [currentIdentityId, setCurrentIdentityId] = useState<string | null>(null); const [fileError, setFileError] = useState<string | null>(null); const [fileStatus, setFileStatus] = useState("Для отправки и открытия файлов нужны разблокированные ключи."); const [inbox, setInbox] = useState<FileRecord[]>([]); const [recipientEmail, setRecipientEmail] = useState(""); const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [sentFiles, setSentFiles] = useState<FileRecord[]>([]); const [shareError, setShareError] = useState<string | null>(null); const [shareExpiresAt, setShareExpiresAt] = useState(""); const [shareFile, setShareFile] = useState<File | null>(null); const [shareLink, setShareLink] = useState(""); const [shareMode, setShareMode] = useState<ShareMode>("secret-link"); const [shareRecipientEmail, setShareRecipientEmail] = useState(""); const [shareStatus, setShareStatus] = useState("Ссылку можно создать без входа."); const [activeShares, setActiveShares] = useState<ShareRecord[]>([]); const [burnAfterReading, setBurnAfterReading] = useState(false); const [setupConfirm, setSetupConfirm] = useState(""); const [setupPassphrase, setSetupPassphrase] = useState(""); const [, setStatus] = useState(""); const [trustedDeviceRemembered, setTrustedDeviceRemembered] = useState(false); const [unlockPassphrase, setUnlockPassphrase] = useState(""); const [useTrustedDevice, setUseTrustedDevice] = useState(false); const [vaultError, setVaultError] = useState<string | null>(null); const [vaultMaterial, setVaultMaterial] = useState<KeyVaultMaterial | null>(null); const [vaultRecord, setVaultRecord] = useState<VaultRecord | null>(null); const [vaultStatus, setVaultStatus] = useState<VaultStatus>(accessToken ? "loading" : "signed-out"); useEffect(() => { function syncPageFromHistory() { setActivePage(resolvePage(window.location.pathname)); } window.addEventListener("popstate", syncPageFromHistory); return () => window.removeEventListener("popstate", syncPageFromHistory); }, []); function navigate(page: AppPage) { const item = navigationItems.find((navigationItem) => navigationItem.id === page); if (!item) { return; } window.history.pushState(null, "", item.path); setActivePage(page); } useEffect(() => { if (window.location.pathname !== "/callback") { return; } async function completeLogin() { try { setStatus("Завершаю вход..."); const callback = parseOAuthCallback(window.location.href); const token = await exchangeAuthorizationCode(oauthConfig(), callback); setAccessToken(token.access_token); const returnPath = sessionStorage.getItem("safedrop:returnPath"); sessionStorage.removeItem("safedrop:returnPath"); window.history.replaceState(null, "", returnPath ?? "/"); setActivePage(resolvePage(returnPath ?? "/")); setStatus("Вход выполнен."); } catch (error) { setStatus(error instanceof Error ? error.message : "Не удалось завершить вход."); } } void completeLogin(); }, []); useEffect(() => { let cancelled = false; if (!accessToken) { setCurrentIdentityId(null); setTrustedDeviceRemembered(false); setVaultRecord(null); setVaultMaterial(null); setVaultStatus("signed-out"); return; } async function loadVault() { try { setVaultError(null); setVaultStatus("loading"); const payload = await getCurrentIdentity(apiUrl, accessToken ?? ""); setCurrentIdentityId(payload.identityId ?? null); const lookup = await getVault(apiUrl, accessToken ?? ""); if (cancelled) { return; } if (lookup.status === "missing") { setVaultRecord(null); setVaultMaterial(null); setVaultStatus("missing"); return; } setVaultRecord(lookup.vault); setVaultMaterial(null); if (payload.identityId) { const restored = await restoreTrustedDeviceVault(payload.identityId, lookup.vault); if (cancelled) { return; } if (restored.status === "ready") { setTrustedDeviceRemembered(true); setVaultMaterial(restored.material); setVaultStatus("ready"); setStatus("Ключи открыты для этого устройства."); return; } setTrustedDeviceRemembered(false); if (restored.status === "stale") { setStatus("Введите пароль ключей заново."); } if (restored.status === "error") { setStatus(restored.error); } } setVaultStatus("locked"); } catch (error) { if (!cancelled) { setVaultError(error instanceof Error ? error.message : "Не удалось загрузить ключи."); setVaultStatus("signed-out"); } } } void loadVault(); return () => { cancelled = true; }; }, [accessToken]); const refreshFiles = useCallback(async () => { if (!accessToken) { setFileError("Сначала войдите в аккаунт."); return; } try { setFileError(null); const [nextInbox, nextSent] = await Promise.all([listInbox(apiUrl, accessToken), listSent(apiUrl, accessToken)]); setInbox(nextInbox); setSentFiles(nextSent); setFileStatus(`Списки обновлены. Входящие: ${nextInbox.length}, исходящие: ${nextSent.length}.`); } catch (error) { setFileError(error instanceof Error ? error.message : "Не удалось обновить списки файлов."); } }, [accessToken]); const refreshActiveShares = useCallback(async () => { if (!accessToken) { setActiveShares([]); return; } try { const shares = await listActiveShares(apiUrl, accessToken); setActiveShares(shares); } catch (error) { setShareError(error instanceof Error ? error.message : "Не удалось обновить список ссылок."); } }, [accessToken]); useEffect(() => { if (!accessToken) { setInbox([]); setSentFiles([]); setActiveShares([]); return; } void refreshFiles(); void refreshActiveShares(); }, [accessToken, refreshFiles, refreshActiveShares]); async function login() { const request = await createAuthorizationRequest(oauthConfig()); window.location.assign(request.url); } function logout() { clearAccessToken(); setAccessToken(null); setCurrentIdentityId(null); setActiveShares([]); setInbox([]); setSentFiles([]); setTrustedDeviceRemembered(false); setVaultMaterial(null); setVaultRecord(null); setVaultStatus("signed-out"); setStatus("Вход завершен."); const logoutUrl = new URL("/logout", authUrl); logoutUrl.searchParams.set("return_to", window.location.origin); window.location.assign(logoutUrl.toString()); } async function rememberCurrentDevice(vault: VaultRecord, material: KeyVaultMaterial): Promise<boolean> { if (!currentIdentityId) { setVaultError("Не удалось определить текущего пользователя."); return false; } try { await rememberTrustedDeviceVault(currentIdentityId, vault, material); return true; } catch (error) { setVaultError(error instanceof Error ? error.message : "Не удалось запомнить это устройство."); return false; } } async function setupVault(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); if (!accessToken) { setVaultError("Сначала войдите в аккаунт."); return; } if (setupPassphrase.length < 12) { setVaultError("Пароль ключей должен быть не короче 12 символов."); return; } if (setupPassphrase !== setupConfirm) { setVaultError("Пароль ключей и подтверждение не совпадают."); return; } if (useTrustedDevice && !currentIdentityId) { setVaultError("Не удалось определить текущего пользователя."); return; } try { setVaultError(null); setStatus("Создаю ключи..."); const created = await createEncryptedVault(setupPassphrase); const saved = await saveVault(apiUrl, accessToken, created.request); const remembered = useTrustedDevice ? await rememberCurrentDevice(saved, created.material) : false; setTrustedDeviceRemembered(remembered); setVaultRecord(saved); setVaultMaterial(created.material); setVaultStatus("ready"); setSetupPassphrase(""); setSetupConfirm(""); setUseTrustedDevice(false); setStatus(remembered ? "Ключи созданы. Это устройство запомнено." : "Ключи созданы."); } catch (error) { setVaultError(error instanceof Error ? error.message : "Не удалось создать ключи."); } } async function unlockVault(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); if (!vaultRecord) { setVaultError("Ключи еще не загружены."); return; } if (useTrustedDevice && !currentIdentityId) { setVaultError("Не удалось определить текущего пользователя."); return; } try { setVaultError(null); const material = await unlockEncryptedVault(vaultRecord.encryptedVault, unlockPassphrase, vaultRecord.publicKeys); const remembered = useTrustedDevice ? await rememberCurrentDevice(vaultRecord, material) : false; setTrustedDeviceRemembered(remembered); setVaultMaterial(material); setVaultStatus("ready"); setUnlockPassphrase(""); setUseTrustedDevice(false); setStatus(remembered ? "Ключи разблокированы. Это устройство запомнено." : "Ключи разблокированы."); } catch (error) { setVaultError(error instanceof Error ? error.message : "Не удалось разблокировать ключи."); } } function lockVault() { setVaultMaterial(null); setVaultStatus(vaultRecord ? "locked" : "missing"); setStatus("Ключи заблокированы."); } async function forgetTrustedDevice() { if (!currentIdentityId) { setVaultError("Не удалось определить текущего пользователя."); return; } try { await forgetTrustedDeviceVault(currentIdentityId); setTrustedDeviceRemembered(false); setVaultMaterial(null); setVaultStatus(vaultRecord ? "locked" : "missing"); setStatus("Устройство забыто. Для следующего открытия потребуется пароль ключей."); } catch (error) { setVaultError(error instanceof Error ? error.message : "Не удалось забыть это устройство."); } } async function rotateKeys() { if (!accessToken) { setVaultError("Сначала войдите в аккаунт."); return; } if (!vaultRecord || !vaultMaterial) { setVaultError("Перед созданием новой версии нужно разблокировать ключи."); return; } try { setVaultError(null); setStatus("Создаю новую версию ключей..."); const rotation = await rotateEncryptedVault({ material: vaultMaterial, vaultRecord }); const rotated = await rotateVault(apiUrl, accessToken, rotation.request); setVaultRecord(rotated); setVaultMaterial(rotation.material); if (trustedDeviceRemembered && currentIdentityId) { setTrustedDeviceRemembered(await rememberCurrentDevice(rotated, rotation.material)); } setVaultStatus("ready"); setStatus(`Новая версия ключей создана: ${rotation.material.activeKeyVersion}.`); } catch (error) { setVaultError(error instanceof Error ? error.message : "Не удалось создать новую версию ключей."); } } async function sendFile(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); if (!accessToken) { setFileError("Сначала войдите в аккаунт."); return; } if (!vaultMaterial) { setFileError("Ключи заблокированы. Разблокируйте их перед отправкой."); return; } if (!selectedFiles.length) { setFileError("Выберите один или несколько файлов."); return; } if (!recipientEmail.trim()) { setFileError("Укажите email получателя."); return; } try { setFileError(null); setFileStatus("Проверяю получателя..."); const keys = await getPublicKeysByEmail(apiUrl, recipientEmail.trim()); const recipientPublicKey = selectActivePublicKey(keys.publicKeys); const sentIds: string[] = []; for (const [index, file] of selectedFiles.entries()) { setFileStatus(`Подготавливаю файл ${index + 1}/${selectedFiles.length}...`); const bytes = new Uint8Array(await file.arrayBuffer()); const envelope = await createSignedFileRequest({ bytes, fileName: file.name, mimeType: file.type || "application/octet-stream", recipient: { identityId: keys.identityId, publicKey: recipientPublicKey }, sender: vaultMaterial, }); setFileStatus(`Отправляю файл ${index + 1}/${selectedFiles.length}...`); const saved = await uploadEncryptedFile(apiUrl, accessToken, envelope.request, envelope.ciphertext); sentIds.push(saved.id); } setSelectedFiles([]); setShareFile(null); setFileStatus(`Отправлено файлов: ${sentIds.length}.`); await refreshFiles(); } catch (error) { setFileError(error instanceof Error ? error.message : "Ошибка отправки файла."); setFileStatus("Ошибка"); } } async function decryptInboxFile(fileId: string) { if (!accessToken) { setFileError("Сначала войдите в аккаунт."); return; } if (!vaultMaterial) { setFileError("Ключи заблокированы. Разблокируйте их перед открытием файла."); return; } if (!currentIdentityId) { setFileError("Не удалось определить текущего пользователя."); return; } try { setFileError(null); setFileStatus("Скачиваю файл..."); const details = await getFileDetails(apiUrl, accessToken, fileId); const wrappedKey = details.file.wrappedKeys.find((key) => key.recipientIdentityId === currentIdentityId); if (!wrappedKey) { throw new Error("Этот файл нельзя открыть текущими ключами."); } const ciphertext = await downloadEncryptedBlob(details.download); const digestB64 = toBase64(await sha256(ciphertext)); if (digestB64 !== details.file.ciphertextSha256B64) { throw new Error("Файл поврежден или был изменен."); } let signatureStatus: "verified" | "unsigned" = "unsigned"; if (details.file.senderSignature) { setFileStatus("Проверяю отправителя..."); const senderKeys = await getPublicKeys(apiUrl, details.file.senderIdentityId); const senderKey = senderKeys.publicKeys.find( (key) => key.version === details.file.senderSignature?.senderKeyVersion, ); if (!senderKey || !(await verifyFileMetadataSignature(details.file, senderKey))) { await updateFileReadState(apiUrl, accessToken, fileId, { read: false, signatureStatus: "failed" }); await refreshFiles(); throw new Error("Подпись отправителя не прошла проверку."); } signatureStatus = "verified"; } setFileStatus("Открываю файл..."); const privateKey = getEncryptionPrivateKey(vaultMaterial, wrappedKey.recipientKeyVersion); const fileKey = await unwrapFileKey(wrappedKey.wrappedKeyB64, privateKey); const plaintext = await decryptFileBytes(fileKey, ciphertext, details.file.aesGcmIvB64); savePlaintextFile(plaintext, details.file.fileName, details.file.mimeType); await updateFileReadState(apiUrl, accessToken, fileId, { read: true, signatureStatus }); await refreshFiles(); setFileStatus(`Файл расшифрован: ${details.file.fileName}.`); } catch (error) { setFileError(error instanceof Error ? error.message : "Ошибка расшифровки файла."); setFileStatus("Ошибка"); } } async function markAllInboxRead() { if (!accessToken) { setFileError("Сначала войдите в аккаунт."); return; } try { setFileError(null); const updated = await markInboxRead(apiUrl, accessToken); await refreshFiles(); setFileStatus(`Прочитано отмечено: ${updated}.`); } catch (error) { setFileError(error instanceof Error ? error.message : "Не удалось отметить входящие прочитанными."); } } async function clearFileHistory(mode: "inbox" | "sent") { if (!accessToken) { setFileError("Сначала войдите в аккаунт."); return; } try { setFileError(null); const updated = mode === "inbox" ? await clearInboxHistory(apiUrl, accessToken) : await clearSentHistory(apiUrl, accessToken); await refreshFiles(); setFileStatus(mode === "inbox" ? `Очищено входящих: ${updated}.` : `Очищено отправленных: ${updated}.`); } catch (error) { setFileError(error instanceof Error ? error.message : "Не удалось очистить историю."); } } async function createAnonymousShare(event: React.FormEvent<HTMLFormElement>) { event.preventDefault(); if (!shareFile) { setShareError("Выберите файл."); return; } if (shareMode === "recipient-public-key" && !shareRecipientEmail.trim()) { setShareError("Укажите email получателя."); return; } try { setShareError(null); setShareLink(""); setShareStatus("Подготавливаю файл..."); const bytes = new Uint8Array(await shareFile.arrayBuffer()); const encrypted = await encryptFileBytes(bytes); if (shareMode === "secret-link") { const fragmentKey = await exportFileKeyToFragment(encrypted.fileKey); setShareStatus("Создаю ссылку и отправляю файл..."); const created = await createSecretShare( apiUrl, { aesGcmIvB64: encrypted.aesGcmIvB64, burnAfterReading, ciphertextSha256B64: encrypted.ciphertextSha256B64, expiresAt: shareExpiresAt ? new Date(shareExpiresAt).toISOString() : undefined, fileName: shareFile.name, mimeType: shareFile.type || "application/octet-stream", sizeBytes: bytes.byteLength, }, encrypted.ciphertext, accessToken, ); setShareLink(buildSecretShareUrl(window.location.origin, created.share.token, fragmentKey)); setSelectedFiles([]); setShareFile(null); await refreshActiveShares(); setShareStatus("Ссылка создана. Скопируйте ее целиком."); return; } setShareStatus("Проверяю получателя..."); const keys = await getPublicKeysByEmail(apiUrl, shareRecipientEmail.trim()); const recipientPublicKey = selectActivePublicKey(keys.publicKeys); const wrappedKey = await wrapFileKey(encrypted.fileKey, { identityId: keys.identityId, publicKey: recipientPublicKey, }); setShareStatus("Создаю ссылку получателю и отправляю файл..."); const created = await createRecipientShare( apiUrl, { aesGcmIvB64: encrypted.aesGcmIvB64, burnAfterReading, ciphertextSha256B64: encrypted.ciphertextSha256B64, expiresAt: shareExpiresAt ? new Date(shareExpiresAt).toISOString() : undefined, fileName: shareFile.name, mimeType: shareFile.type || "application/octet-stream", recipientIdentityId: keys.identityId, recipientKeyVersion: recipientPublicKey.version, sizeBytes: bytes.byteLength, wrappedKeyB64: wrappedKey.wrappedKeyB64, }, encrypted.ciphertext, accessToken, ); setShareLink(buildRecipientShareUrl(window.location.origin, created.share.token)); setSelectedFiles([]); setShareFile(null); await refreshActiveShares(); setShareStatus("Ссылка получателю создана. Отправитель не подтвержден подписью."); } catch (error) { setShareError(error instanceof Error ? error.message : "Не удалось создать ссылку."); setShareStatus("Ошибка"); } } async function copyShareLink() { if (!shareLink) { return; } await navigator.clipboard.writeText(shareLink); setShareStatus("Ссылка скопирована."); } async function revokeActiveShare(shareId: string) { if (!accessToken) { setShareError("Сначала войдите в аккаунт."); return; } try { setShareError(null); await revokeShare(apiUrl, accessToken, shareId); setShareStatus("Ссылка отозвана."); await refreshActiveShares(); } catch (error) { setShareError(error instanceof Error ? error.message : "Не удалось отозвать ссылку."); } } const vaultPanelProps = { onForgetTrustedDevice: () => void forgetTrustedDevice(), onLockVault: lockVault, onRotateKeys: () => void rotateKeys(), onSetupConfirmChange: setSetupConfirm, onSetupPassphraseChange: setSetupPassphrase, onSetupVault: setupVault, onTrustedDeviceChange: setUseTrustedDevice, onUnlockPassphraseChange: setUnlockPassphrase, onUnlockVault: unlockVault, setupConfirm, setupPassphrase, trustedDeviceRemembered, unlockPassphrase, useTrustedDevice, vaultError, vaultMaterial, vaultRecord, vaultStatus, }; const sendPageProps = { accessToken, activeShares, burnAfterReading, fileError, fileStatus, onCopyShareLink: () => void copyShareLink(), onCreateAnonymousShare: createAnonymousShare, onRecipientEmailChange: setRecipientEmail, onRefreshActiveShares: () => void refreshActiveShares(), onRevokeShare: (shareId: string) => void revokeActiveShare(shareId), onSelectedFilesChange: setSelectedFiles, onSendFile: sendFile, onShareBurnAfterReadingChange: setBurnAfterReading, onShareExpiresAtChange: setShareExpiresAt, onShareFileChange: setShareFile, onShareModeChange: setShareMode, onShareRecipientEmailChange: setShareRecipientEmail, recipientEmail, selectedFiles, shareError, shareExpiresAt, shareFile, shareLink, shareMode, shareRecipientEmail, shareStatus, vaultStatus, }; const page = (() => { if (activePage === "send") { return <SendPage {...sendPageProps} />; } if (activePage === "inbox") { return ( <InboxPage files={inbox} onClearHistory={() => void clearFileHistory("inbox")} onDecryptInboxFile={(fileId) => void decryptInboxFile(fileId)} onMarkAllRead={() => void markAllInboxRead()} onRefreshFiles={() => void refreshFiles()} /> ); } if (activePage === "sent") { return ( <SentPage files={sentFiles} onClearHistory={() => void clearFileHistory("sent")} onRefreshFiles={() => void refreshFiles()} /> ); } if (activePage === "keys") { return <KeysPage {...vaultPanelProps} />; } return ( <WorkspacePage accessToken={accessToken} inbox={inbox} onNavigateInbox={() => navigate("inbox")} onNavigateKeys={() => navigate("keys")} onNavigateSend={() => navigate("send")} vaultStatus={vaultStatus} /> ); })(); return ( <AppShell accessToken={accessToken} activePage={activePage} currentIdentityId={currentIdentityId} onLogin={() => void login()} onLogout={logout} onNavigate={navigate} trustedDeviceRemembered={trustedDeviceRemembered} vaultStatus={vaultStatus} > {page} </AppShell> ); } export function AppController() { return <Root dashboard={<DashboardPage />} />; }