diff --git a/frontend/src/api/response.ts b/frontend/src/api/response.ts new file mode 100644 index 0000000..e5ea2ab --- /dev/null +++ b/frontend/src/api/response.ts @@ -0,0 +1,24 @@ +export interface LetterResponseData { + public_id: string; + type: "KEPT" | "SENT" | "VAULT"; + status: "DRAFT" | "SEALED" | "BURNED"; + encrypted_content: string; + encrypted_metadata: string; + encrypted_dek: string; + unlock_at: string | null; + sealed_at: string | null; + created_at: string; + updated_at: string; + images: LetterImageData[]; +} + +export interface LetterImageData { + public_id: string; + file: string; + file_name: string; +} + +export interface LetterMetadata { + recipient: string; + tags?: string[]; +} diff --git a/frontend/src/hooks/useLetters.tsx b/frontend/src/hooks/useLetters.tsx index 154c2d1..4597e75 100644 --- a/frontend/src/hooks/useLetters.tsx +++ b/frontend/src/hooks/useLetters.tsx @@ -1,108 +1,92 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "../api/apiClient"; +import type { LetterMetadata, LetterResponseData } from "../api/response"; import { endpoints } from "../config/endpoints"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; -export interface Letter { - public_id: string; - type: "KEPT" | "VAULT" | "SENT"; - status: "DRAFT" | "SEALED" | "BURNED"; - updated_at: string; - sealed_at?: string; - unlock_at: string; - encrypted_metadata: string; - encrypted_content: string; - encrypted_dek: string; -} - -export interface LetterMetadata { - recipient: string; - tags?: string[]; -} - -export interface ProcessedLetter extends Letter { - metadata: LetterMetadata; +export interface ProcessedLetter extends LetterResponseData { + metadata: LetterMetadata; } async function decryptLettersMetadata( - letters: Letter[], - masterKey: CryptoKey, + letters: LetterResponseData[], + masterKey: CryptoKey, ): Promise { - const cryptoUtils = new CryptoUtils(); + const cryptoUtils = new CryptoUtils(); - return Promise.all( - letters.map(async (letter) => { - try { - const metadata = (await cryptoUtils.decryptMetadata( - { - encrypted_content: letter.encrypted_metadata, - encrypted_dek: letter.encrypted_dek, - }, - masterKey, - )) as LetterMetadata; + return Promise.all( + letters.map(async (letter) => { + try { + const metadata = (await cryptoUtils.decryptMetadata( + { + encrypted_content: letter.encrypted_metadata, + encrypted_dek: letter.encrypted_dek, + }, + masterKey, + )) as LetterMetadata; - return { ...letter, metadata }; - } catch (_err) { - return { - ...letter, - metadata: { recipient: "Encrypted Letter" }, - }; - } - }), - ); + return { ...letter, metadata }; + } catch { + return { + ...letter, + metadata: { recipient: "Encrypted Letter" }, + }; + } + }), + ); } export function useLetters() { - const [letters, setLetters] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isAuthRequired, setIsAuthRequired] = useState(false); - const { masterKey } = useKeyStore(); + const [letters, setLetters] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isAuthRequired, setIsAuthRequired] = useState(false); + const { masterKey } = useKeyStore(); - // to fetch the letters and decryypt the metadata on load - useEffect(() => { - if (!masterKey) { - setIsAuthRequired(true); - return; + // to fetch the letters and decryypt the metadata on load + useEffect(() => { + if (!masterKey) { + setIsAuthRequired(true); + return; + } + setIsAuthRequired(false); + setError(null); + setLoading(true); + api + .get(endpoints.LETTERS) + .then((res) => decryptLettersMetadata(res.data, masterKey)) + .then((decrypted) => { + setLetters( + decrypted.sort( + (a, b) => + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime(), + ), + ); + }) + .catch((err) => { + setError(err); + }) + .finally(() => setLoading(false)); + }, [masterKey]); + + const drawerItems = useMemo(() => { + return { + drafts: letters.filter((l) => l.status === "DRAFT"), + kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), + vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"), + sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), + }; + }, [letters]); + + if (error) { + throw error; } - setIsAuthRequired(false); - setError(null); - setLoading(true); - api - .get(endpoints.LETTERS) - .then((res) => decryptLettersMetadata(res.data, masterKey)) - .then((decrypted) => { - setLetters( - decrypted.sort( - (a, b) => - new Date(b.updated_at).getTime() - - new Date(a.updated_at).getTime(), - ), - ); - }) - .catch((err) => { - setError(err); - }) - .finally(() => setLoading(false)); - }, [masterKey]); - const drawerItems = useMemo(() => { return { - drafts: letters.filter((l) => l.status === "DRAFT"), - kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), - vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"), - sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), + ...drawerItems, + loading, + isAuthRequired, }; - }, [letters]); - - if (error) { - throw error; - } - - return { - ...drawerItems, - loading, - isAuthRequired, - }; } diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index 4840eaa..1982032 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -1,4 +1,5 @@ import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react"; +import type { AxiosResponse } from "axios"; import { useEffect, useRef, useState } from "react"; import { type NavigateFunction, @@ -7,6 +8,7 @@ import { useParams, } from "react-router-dom"; import { api } from "../api/apiClient"; +import type { LetterImageData, LetterResponseData } from "../api/response"; import { type CanvasJSON, type CanvasTools, @@ -103,89 +105,107 @@ export default function Reader() { return; } + const decryptImages = async ( + canvasData: CanvasJSON, + images: LetterImageData[], + encrypted_dek: string, + cryptoUtils: CryptoUtils, + ) => { + if (!images?.length) return; + const isShared = !!sharingKey; + try { + if (isShared) { + await decryptCanvasImagesWithSharingKey( + canvasData, + images, + sharingKey, + cryptoUtils, + ); + } else { + await decryptCanvasImages( + canvasData, + images, + encrypted_dek, + // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true + masterKey!, + cryptoUtils, + ); + } + } catch (err) { + setLogTrace({ + message: + "Failed to decrypt elements. Images might not render in the letter as intended.", + log: err instanceof Error ? err.message : "Unknown error", + type: "WARN", + }); + } + }; + + const decryptLetterData = async ( + data: LetterResponseData, + cryptoUtils: CryptoUtils, + ) => { + const isShared = !!sharingKey; + const { + encrypted_content, + encrypted_metadata, + encrypted_dek, + images, + updated_at, + } = data; + + // Decrypt Metadata + const decryptedMetadata = isShared + ? await cryptoUtils.decryptMetadataWithSharingKey( + encrypted_metadata, + sharingKey, + ) + : await cryptoUtils.decryptMetadata( + { encrypted_content: encrypted_metadata, encrypted_dek }, + // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true + masterKey!, + ); + setMetadata({ + ...(decryptedMetadata as LetterMetadata), + updated_at, + }); + + // Decrypt Content + const decryptedContent = isShared + ? await cryptoUtils.decryptLetterWithSharingKey( + encrypted_content, + sharingKey, + ) + : await cryptoUtils.decryptLetter( + { encrypted_content, encrypted_dek }, + // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true + masterKey!, + ); + + const canvasData: CanvasJSON = JSON.parse(decryptedContent); + await decryptImages(canvasData, images, encrypted_dek, cryptoUtils); + setDecryptedCanvasData(canvasData); + }; + const loadAndDecrypt = async () => { try { - const response = await api.get(`${endpoints.LETTERS}${public_id}/`); - const { - encrypted_content, - encrypted_metadata, - encrypted_dek, - images, - updated_at, - status, - } = response.data; + const response: AxiosResponse = await api.get( + `${endpoints.LETTERS}${public_id}/`, + ); + const data = response.data; - if (status === "BURNED") + if (data.status === "BURNED") throw new Error("This letter has been burned."); - if (encrypted_dek) setEncryptedDek(encrypted_dek); + if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek); + + const isDecryptionKeyAvailable = data.encrypted_dek && masterKey; + if (!(!!sharingKey || isDecryptionKeyAvailable)) { + throw new Error("Auth required: Decryption key is not available"); + } const cryptoUtils = new CryptoUtils(); - const isShared = !!sharingKey; - - if (isShared && !encrypted_content) throw new Error("Content missing"); - const isDecryptionKeyAvailable = encrypted_dek && masterKey; - if (!(isShared || isDecryptionKeyAvailable)) - throw new Error("Auth required: Decryption key is not available"); - - // Decrypt Metadata - const decryptedMetadata = isShared - ? await cryptoUtils.decryptMetadataWithSharingKey( - encrypted_metadata, - sharingKey, - ) - : await cryptoUtils.decryptMetadata( - { encrypted_content: encrypted_metadata, encrypted_dek }, - // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true - masterKey!, - ); - setMetadata({ - ...(decryptedMetadata as LetterMetadata), - updated_at, - }); - - // Decrypt Content - const decryptedContent = isShared - ? await cryptoUtils.decryptLetterWithSharingKey( - encrypted_content, - sharingKey, - ) - : await cryptoUtils.decryptLetter( - { encrypted_content, encrypted_dek }, - // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true - masterKey!, - ); - - const canvasData: CanvasJSON = JSON.parse(decryptedContent); - - try { - // Decrypt Images - if (images?.length > 0) { - isShared - ? await decryptCanvasImagesWithSharingKey( - canvasData, - images, - sharingKey, - cryptoUtils, - ) - : await decryptCanvasImages( - canvasData, - images, - encrypted_dek, - // biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true - masterKey!, - cryptoUtils, - ); - } - } catch (err) { - setLogTrace({ - message: - "Failed to decrypt elements. Images might not render in the letter as intended.", - log: err instanceof Error ? err.message : "Unknown error", - type: "WARN", - }); - } - setDecryptedCanvasData(canvasData); + await decryptLetterData(data, cryptoUtils); } catch (err) { setLogTrace({ message: `Failed to load letter ☹`, diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index 942a8aa..53fe9e7 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -1,3 +1,5 @@ +import type { LetterMetadata } from "../api/response"; + export interface EncryptedLetter { encrypted_content: string; encrypted_dek: string; @@ -275,7 +277,7 @@ export class CryptoUtils { } public async encryptMetadata( - metadata: Record, + metadata: LetterMetadata, masterKey: CryptoKey, ): Promise { const { encryptedContent, encrypted_dek, sharingKey } = @@ -290,7 +292,7 @@ export class CryptoUtils { public async decryptMetadata( encrypted_metadata: EncryptedLetter, masterKey: CryptoKey, - ): Promise> { + ): Promise { const bytes = await this.openEnvelope( encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_dek, @@ -303,7 +305,7 @@ export class CryptoUtils { public async decryptMetadataWithSharingKey( encrypted_content: string, sharingKey: string, - ): Promise> { + ): Promise { const bytes = await this.openEnvelopeWithSharingKey( encrypted_content, sharingKey, diff --git a/frontend/src/utils/letterLogic.test.ts b/frontend/src/utils/letterLogic.test.ts index 0cf634f..94180bf 100644 --- a/frontend/src/utils/letterLogic.test.ts +++ b/frontend/src/utils/letterLogic.test.ts @@ -221,7 +221,11 @@ describe("letterLogic image helpers", () => { ], }; const remoteImages = [ - { file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, + { + public_id: "1234", + file_name: "photo.png.bin", + file: "https://remote/photo.png.bin", + }, ]; vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) }); diff --git a/frontend/src/utils/letterLogic.ts b/frontend/src/utils/letterLogic.ts index acc1f17..7c2951f 100644 --- a/frontend/src/utils/letterLogic.ts +++ b/frontend/src/utils/letterLogic.ts @@ -1,4 +1,5 @@ import { api, apiServerUrl, publicApi } from "../api/apiClient"; +import type { LetterImageData } from "../api/response"; import type { CanvasJSON, FabricImageJSON, @@ -111,7 +112,7 @@ export async function decryptCanvasImages( export async function decryptCanvasImagesWithSharingKey( canvasData: CanvasJSON, - remoteImages: { file_name: string; file: string }[], + remoteImages: LetterImageData[], sharingKey: string, cryptoUtils: CryptoUtils, ): Promise {