diff --git a/frontend/src/components/RouteGuards.test.tsx b/frontend/src/components/RouteGuards.test.tsx index ca2e76d..d594508 100644 --- a/frontend/src/components/RouteGuards.test.tsx +++ b/frontend/src/components/RouteGuards.test.tsx @@ -40,7 +40,7 @@ describe("ProtectedRoute", () => { "/protected", ); - expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); + expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument(); expect(screen.queryByText("Secret")).not.toBeInTheDocument(); }); @@ -90,7 +90,7 @@ describe("PublicRoute", () => { , "/public", ); - expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); + expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument(); expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); }); diff --git a/frontend/src/components/SplashScreen.tsx b/frontend/src/components/SplashScreen.tsx index 9a31bd5..e2e4bf6 100644 --- a/frontend/src/components/SplashScreen.tsx +++ b/frontend/src/components/SplashScreen.tsx @@ -7,8 +7,8 @@ export default function SplashScreen() {
-

- Initializing Identity +

+ Unsealing...

diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/DrawerSection.tsx similarity index 100% rename from frontend/src/components/ui/Drawer.tsx rename to frontend/src/components/ui/DrawerSection.tsx diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 91ef70e..714b394 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -2,7 +2,7 @@ import { FeatherIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { useNavigate } from "react-router-dom"; import Logo from "../components/Logo"; -import { DrawerSection } from "../components/ui/Drawer"; +import { DrawerSection } from "../components/ui/DrawerSection"; import { LetterItem } from "../components/ui/LetterItem"; import { PATHS } from "../config/routes"; import { useAuth } from "../hooks/useAuth"; @@ -10,7 +10,7 @@ import { useLetters } from "../hooks/useLetters"; export default function Drawer() { const { user, logout } = useAuth(); - const [openSection, setOpenSection] = useState("kept"); + const [openSection, setOpenSection] = useState(); const navigate = useNavigate(); const { drafts, kept, sent, vault, loading } = useLetters(); diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 19f8b5e..f86dba1 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -35,19 +35,18 @@ export default function Editor() { const canvasRef = useRef(null); const fileInputRef = useRef(null); - // Initial load: Fetch and decrypt existing letter useEffect(() => { if (!public_id || !masterKey) return; const loadExistingLetter = async () => { setIsInitialLoading(true); - const crypto = new CryptoUtils(); + const cryptoUtils = new CryptoUtils(); + try { const res = await api.get(`${endpoints.LETTERS}${public_id}/`); const letterData = res.data; - // Decrypt the metadata (for the recipient field) - const metadata = await crypto.decryptMetadata( + const metadata = await cryptoUtils.decryptMetadata( { encrypted_content: letterData.encrypted_metadata, encrypted_dek: letterData.encrypted_dek, @@ -56,8 +55,7 @@ export default function Editor() { ); setRecipient(metadata.recipient || ""); - // Decrypt the main canvas JSON - const decryptedJsonStr = await crypto.decryptLetter( + const decryptedJsonStr = await cryptoUtils.decryptLetter( { encrypted_content: letterData.encrypted_content, encrypted_dek: letterData.encrypted_dek, @@ -66,16 +64,15 @@ export default function Editor() { ); const canvasData = JSON.parse(decryptedJsonStr); - // Batch decrypt images within the canvas await decryptCanvasImages( canvasData, - letterData.images, + letterData.images ?? [], letterData.encrypted_dek, masterKey, - true, // restore raw files for the editor + cryptoUtils, + true, ); - // Load data into the Fabric canvas requestAnimationFrame(() => { canvasRef.current?.loadData(canvasData); }); @@ -115,14 +112,13 @@ export default function Editor() { const canvasData = canvasRef.current?.getData(); const canvasImages = canvasRef.current?.getImages() || []; - // Secure any new images first const encImageFilesMap = await encryptCanvasImages( canvasData, canvasImages, masterKey, + cryptoUtils, ); - // Encrypt the updated canvas JSON const encrypted_letter = await cryptoUtils.encryptLetter( JSON.stringify(canvasData), masterKey, @@ -186,7 +182,7 @@ export default function Editor() { )} - {/* Sharing Modal */} + {shareLink && (
@@ -233,10 +229,7 @@ export default function Editor() { )} {isSaveSuccess && !shareLink && ( -
+

@@ -245,11 +238,9 @@ export default function Editor() {

)} + {isSealing && ( -
+

@@ -258,6 +249,7 @@ export default function Editor() {

)} +
diff --git a/frontend/src/pages/Reader.test.tsx b/frontend/src/pages/Reader.test.tsx index 975fa45..506eed7 100644 --- a/frontend/src/pages/Reader.test.tsx +++ b/frontend/src/pages/Reader.test.tsx @@ -7,7 +7,6 @@ import { endpoints } from "../config/endpoints"; import { CryptoUtils } from "../utils/crypto"; import Reader from "./Reader"; -// We use the same API_URL logic as our other tests const API_URL = import.meta.env.VITE_API_URL; // Spy on crypto methods so we don't have to do actual decryption in the UI test @@ -63,8 +62,6 @@ describe("Reader Page", () => { it("should load and decrypt the letter when a valid key is provided", async () => { const mockPublicId = "test-uuid"; const mockKey = "fake-key"; - - // Mock the server response using MSW server.use( http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { return HttpResponse.json({ @@ -86,9 +83,8 @@ describe("Reader Page", () => { // Should show loading state first expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument(); - // Eventually should show the decrypted recipient header expect( - await screen.findByText(/A sealed message for Guest/i), + await screen.findByText(/A sealed message for/i), ).toBeInTheDocument(); }); diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx index ed3fdfd..ce2f4f8 100644 --- a/frontend/src/pages/Reader.tsx +++ b/frontend/src/pages/Reader.tsx @@ -1,8 +1,11 @@ import { CrossIcon } from "@phosphor-icons/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import { api } from "../api/apiClient"; -import { ComposeCanvas } from "../components/ui/ComposeCanvas"; +import { + type CanvasTools, + ComposeCanvas, +} from "../components/ui/ComposeCanvas"; import { endpoints } from "../config/endpoints"; import { CryptoUtils } from "../utils/crypto"; import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; @@ -11,10 +14,13 @@ export default function Reader() { const { public_id } = useParams(); const location = useLocation(); const sharingKey = location.hash.replace("#", ""); + + const canvasRef = useRef(null); + const [isDecrypting, setIsDecrypting] = useState(true); const [error, setError] = useState(null); - const [canvasData, setCanvasData] = useState(null); const [metadata, setMetadata] = useState(null); + const [decryptedCanvasData, setDecryptedCanvasData] = useState(null); useEffect(() => { if (!sharingKey) { @@ -28,31 +34,34 @@ export default function Reader() { const response = await api.get(`${endpoints.LETTERS}${public_id}/`); const { encrypted_content, encrypted_metadata, images } = response.data; - const crypto = new CryptoUtils(); + const cryptoUtils = new CryptoUtils(); - // 1. Decrypt metadata using the sharing key from the URL - const decryptedMetadata = await crypto.decryptMetadataWithSharingKey( - encrypted_metadata, - sharingKey, - ); + const decryptedMetadata = + await cryptoUtils.decryptMetadataWithSharingKey( + encrypted_metadata, + sharingKey, + ); setMetadata(decryptedMetadata); - // 2. Decrypt the main letter content - const decryptedContent = await crypto.decryptLetterWithSharingKey( + const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey( encrypted_content, sharingKey, ); const json = JSON.parse(decryptedContent); - // 3. Batch decrypt any images on the canvas if (images && images.length > 0) { - await decryptCanvasImagesWithSharingKey(json, images, sharingKey); + await decryptCanvasImagesWithSharingKey( + json, + images, + sharingKey, + cryptoUtils, + ); } - setCanvasData(json); - setIsDecrypting(false); + setDecryptedCanvasData(json); } catch (err: any) { setError(`Failed to load letter: ${err.message || "Unknown error"}`); + } finally { setIsDecrypting(false); } }; @@ -60,43 +69,66 @@ export default function Reader() { loadAndDecrypt(); }, [public_id, sharingKey]); + useEffect(() => { + if (!isDecrypting && decryptedCanvasData && canvasRef.current) { + canvasRef.current.loadData(decryptedCanvasData); + } + }, [isDecrypting, decryptedCanvasData]); + if (isDecrypting) { return ( -
- -

Decrypting...

+
+
+

Decrypting...

+
); } if (error) { return ( -
-
- - {error} +
+
+

{error}

+
-
); } return ( -
- {metadata?.recipient && ( -
-

- A sealed message for {metadata.recipient} -

+
+
+
+
+ {metadata?.recipient && ( +

+ A sealed message for{" "} + + {metadata.recipient || "Anonymous"} + +

+ )} +
+
- )} - {canvasData && } -
+ +
+ +
+
+ ); } diff --git a/frontend/src/utils/letterLogic.test.ts b/frontend/src/utils/letterLogic.test.ts new file mode 100644 index 0000000..60d55b9 --- /dev/null +++ b/frontend/src/utils/letterLogic.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api/apiClient"; +import { CryptoUtils } from "./crypto"; +import { blobUrlToFile } from "./fileUtils"; +import { + decryptCanvasImages, + decryptCanvasImagesWithSharingKey, + encryptCanvasImages, +} from "./letterLogic"; + +vi.mock("../api/apiClient", () => ({ + api: { + get: vi.fn(), + }, +})); + +vi.mock("./fileUtils", () => ({ + blobUrlToFile: vi.fn(), +})); + +describe("letterLogic image helpers", () => { + let masterKey: CryptoKey; + let crypto: CryptoUtils; + + beforeEach(async () => { + masterKey = await CryptoUtils.deriveMasterKey( + "password123", + "test@example.com", + ); + crypto = new CryptoUtils(); + await crypto.initialize(); + vi.clearAllMocks(); + }); + + describe("encryptCanvasImages", () => { + it("should not encrypt images whose src already ends with .bin", async () => { + const canvasData = { + objects: [ + { type: "Image", src: "already-encrypted.png.bin" }, + { type: "Textbox", text: "hello" }, + ], + }; + + const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage"); + + const uploads = await encryptCanvasImages( + canvasData, + [], + masterKey, + crypto, + ); + + expect(encryptImageSpy).not.toHaveBeenCalled(); + expect(canvasData.objects[0].src).toBe("already-encrypted.png.bin"); + expect(uploads.size).toBe(0); + }); + + it("should encrypt new blob-backed images and return encrypted uploads", async () => { + const file = new File(["img"], "photo.png", { type: "image/png" }); + const canvasData = { + objects: [{ type: "Image", src: "blob:http://localhost/test-image" }], + }; + const canvasImages = [ + { + src: "blob:http://localhost/test-image", + file, + }, + ]; + + vi.spyOn(CryptoUtils.prototype, "encryptImage").mockResolvedValue({ + encryptedBlob: new Blob(["encrypted"], { + type: "application/octet-stream", + }), + encrypted_dek: "wrapped-image-dek", + filename: "photo.png.bin", + }); + + const uploads = await encryptCanvasImages( + canvasData, + canvasImages, + masterKey, + crypto, + ); + + expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1); + expect(canvasData.objects[0].src).toBe("photo.png.bin"); + expect(uploads.size).toBe(1); + expect(uploads.has("photo.png.bin")).toBe(true); + }); + }); + + describe("decryptCanvasImages", () => { + it("should decrypt images and replace src with blob URL", async () => { + const canvasData = { + objects: [ + { type: "Image", src: "photo.png.bin" }, + { type: "Textbox", text: "hello" }, + ], + }; + const remoteImages = [ + { file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, + ]; + + vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) }); + vi.spyOn(CryptoUtils.prototype, "decryptImage").mockResolvedValue( + "blob:http://localhost/decrypted", + ); + + await decryptCanvasImages( + canvasData, + remoteImages, + "wrapped-dek", + masterKey, + crypto, + ); + + expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", { + responseType: "blob", + }); + expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith( + expect.any(Blob), + "wrapped-dek", + masterKey, + ); + expect(canvasData.objects[0].src).toBe("blob:http://localhost/decrypted"); + expect(canvasData.objects[1].text).toBe("hello"); + }); + + it("should include raw file when includeRawFile is true", async () => { + const canvasData = { + objects: [ + { type: "Image", src: "photo.png.bin", _customRawFile: null }, + ], + }; + const remoteImages = [ + { file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, + ]; + + vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) }); + vi.spyOn(CryptoUtils.prototype, "decryptImage").mockResolvedValue( + "blob:http://localhost/decrypted", + ); + vi.mocked(blobUrlToFile).mockResolvedValue( + new File(["raw"], "photo.png.bin"), + ); + + await decryptCanvasImages( + canvasData, + remoteImages, + "wrapped-dek", + masterKey, + crypto, + true, + ); + + expect(blobUrlToFile).toHaveBeenCalledWith( + "blob:http://localhost/decrypted", + "photo.png.bin", + ); + expect(canvasData.objects[0]._customRawFile).toBeInstanceOf(File); + }); + }); + + describe("decryptCanvasImagesWithSharingKey", () => { + it("should decrypt images using sharing key", async () => { + const canvasData = { + objects: [{ type: "Image", src: "photo.png.bin" }], + }; + const remoteImages = [ + { file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, + ]; + + vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) }); + vi.spyOn( + CryptoUtils.prototype, + "decryptImageWithSharingKey", + ).mockResolvedValue("blob:http://localhost/decrypted-shared"); + + await decryptCanvasImagesWithSharingKey( + canvasData, + remoteImages, + "raw-sharing-key", + crypto, + ); + + expect(api.get).toHaveBeenCalledWith("https://remote/photo.png.bin", { + responseType: "blob", + }); + expect( + CryptoUtils.prototype.decryptImageWithSharingKey, + ).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key"); + expect(canvasData.objects[0].src).toBe( + "blob:http://localhost/decrypted-shared", + ); + }); + }); +}); diff --git a/frontend/src/utils/letterLogic.ts b/frontend/src/utils/letterLogic.ts index 9713832..09f2e6f 100644 --- a/frontend/src/utils/letterLogic.ts +++ b/frontend/src/utils/letterLogic.ts @@ -1,18 +1,18 @@ import { api } from "../api/apiClient"; -import { CryptoUtils } from "./crypto"; +import type { CryptoUtils } from "./crypto"; import { blobUrlToFile } from "./fileUtils"; -// Helpers to handle the complex process of locking and unlocking letters with images. +export interface CanvasImageRef { + src: string; + file: File; +} -const crypto = new CryptoUtils(); - -// Goes through the canvas objects and decrypts any images found. -// This is used when opening an existing letter. export async function decryptCanvasImages( canvasData: any, remoteImages: any[], encrypted_dek: string, masterKey: CryptoKey, + cryptoUtils: CryptoUtils, includeRawFile = false, ) { if (!canvasData?.objects) return; @@ -22,33 +22,32 @@ export async function decryptCanvasImages( ); for (const obj of canvasData.objects) { - if (obj.type === "Image" && typeof obj.src === "string") { - const remoteUrl = imageMap.get(obj.src); - if (!remoteUrl) continue; + if (obj.type !== "Image") continue; - try { - const res = await api.get(remoteUrl, { responseType: "blob" }); - const blobUrl = await crypto.decryptImage( - res.data, - encrypted_dek, - masterKey, - ); + const originalFilename = obj.src; + const remoteUrl = imageMap.get(originalFilename); + if (!remoteUrl) continue; - obj.src = blobUrl; - if (includeRawFile) { - // We need the raw file in the editor so we can re-encrypt it if the user saves again. - obj._customRawFile = await blobUrlToFile(blobUrl, obj.src); - } - } catch (_err) {} + const res = await api.get(remoteUrl, { responseType: "blob" }); + const blobUrl = await cryptoUtils.decryptImage( + res.data, + encrypted_dek, + masterKey, + ); + + obj.src = blobUrl; + + if (includeRawFile) { + obj._customRawFile = await blobUrlToFile(blobUrl, originalFilename); } } } -// Decrypts canvas images using just the sharing key (for guest access). export async function decryptCanvasImagesWithSharingKey( canvasData: any, remoteImages: any[], sharingKey: string, + cryptoUtils: CryptoUtils, ) { if (!canvasData?.objects) return; @@ -57,36 +56,34 @@ export async function decryptCanvasImagesWithSharingKey( ); for (const obj of canvasData.objects) { - if (obj.type === "Image" && typeof obj.src === "string") { - const remoteUrl = imageMap.get(obj.src); - if (!remoteUrl) continue; + if (obj.type !== "Image") continue; - try { - const res = await api.get(remoteUrl, { responseType: "blob" }); - obj.src = await crypto.decryptImageWithSharingKey(res.data, sharingKey); - } catch (_err) {} - } + const remoteUrl = imageMap.get(obj.src); + if (!remoteUrl) continue; + + const res = await api.get(remoteUrl, { responseType: "blob" }); + obj.src = await cryptoUtils.decryptImageWithSharingKey( + res.data, + sharingKey, + ); } } -// Encrypts any new images the user added to the canvas. -// Returns a map of filenames to encrypted blobs for uploading. export async function encryptCanvasImages( canvasData: any, - canvasImages: { src: string; file: File }[], + canvasImages: CanvasImageRef[], masterKey: CryptoKey, + cryptoUtils: CryptoUtils, ) { const encryptedFiles = new Map(); const filenameMapping = new Map(); - await crypto.initialize(); - for (const img of canvasImages) { - // If it already ends in .bin, it was already encrypted. if (img.src.endsWith(".bin")) continue; + if (!img.file) continue; try { - const { filename, encryptedBlob } = await crypto.encryptImage( + const { filename, encryptedBlob } = await cryptoUtils.encryptImage( img.file, masterKey, ); @@ -95,7 +92,6 @@ export async function encryptCanvasImages( } catch (_err) {} } - // Update the canvas JSON to use the new encrypted filenames instead of blob URLs. if (canvasData?.objects) { canvasData.objects = canvasData.objects.map((obj: any) => { if (obj.type === "Image" && filenameMapping.has(obj.src)) {