From 2776aac69afcd425f7ef2f544dd72a9faa4a83fc Mon Sep 17 00:00:00 2001 From: ramvignesh-b Date: Mon, 13 Apr 2026 13:46:55 +0530 Subject: [PATCH] feat: implement Reader page for viewing encrypted letters and add read-only mode to ComposeCanvas --- backend/letters/views.py | 14 +- frontend/src/App.tsx | 2 + frontend/src/components/ui/ComposeCanvas.tsx | 120 +++++----- frontend/src/hooks/useAuth.test.ts | 34 +-- frontend/src/pages/Drawer.test.tsx | 2 +- frontend/src/pages/Drawer.tsx | 5 + frontend/src/pages/Editor.tsx | 238 +++++++++---------- frontend/src/pages/Login.test.tsx | 78 ++++++ frontend/src/pages/Reader.test.tsx | 117 +++++++++ frontend/src/pages/Reader.tsx | 103 ++++++++ frontend/src/utils/crypto.test.ts | 37 +++ frontend/src/utils/crypto.ts | 95 +++++--- frontend/src/utils/fileUtils.ts | 17 ++ frontend/src/utils/letterLogic.ts | 115 +++++++++ 14 files changed, 749 insertions(+), 228 deletions(-) create mode 100644 frontend/src/pages/Login.test.tsx create mode 100644 frontend/src/pages/Reader.test.tsx create mode 100644 frontend/src/pages/Reader.tsx create mode 100644 frontend/src/utils/fileUtils.ts create mode 100644 frontend/src/utils/letterLogic.ts diff --git a/backend/letters/views.py b/backend/letters/views.py index 4d6be6d..1a8e44f 100644 --- a/backend/letters/views.py +++ b/backend/letters/views.py @@ -1,5 +1,5 @@ from rest_framework import generics -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from letters.models import Letter, LetterImage @@ -18,11 +18,19 @@ class LetterView(generics.ListCreateAPIView): class LetterDetailView(generics.RetrieveUpdateDestroyAPIView): serializer_class = LetterSerializer - permission_classes = [IsAuthenticated] lookup_field = "public_id" + def get_permissions(self): + if self.request.method == "GET": + return [AllowAny()] + return [IsAuthenticated()] + def get_queryset(self): - return Letter.objects.filter(user=self.request.user) + if self.request.user.is_authenticated: + # author can see all their letters (DRAFT, SEALED, etc.) + return Letter.objects.filter(user=self.request.user) + # guests can ONLY see SEALED letters + return Letter.objects.filter(status=Letter.Status.SEALED) def put(self, request, public_id): # upsert: create if doesn't exist, else update diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index da0c788..d0ced43 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Editor from "./pages/Editor"; // Pages import Home from "./pages/Home"; import Login from "./pages/Login"; +import Reader from "./pages/Reader"; import Register from "./pages/Register"; import VerifyEmail from "./pages/VerifyEmail"; @@ -79,6 +80,7 @@ export default function App() { } /> + } /> } /> diff --git a/frontend/src/components/ui/ComposeCanvas.tsx b/frontend/src/components/ui/ComposeCanvas.tsx index c8ada3f..1614e53 100644 --- a/frontend/src/components/ui/ComposeCanvas.tsx +++ b/frontend/src/components/ui/ComposeCanvas.tsx @@ -17,7 +17,10 @@ export interface FabricImageWithFile extends fabric.FabricImage { _customRawFile: File; } -export const ComposeCanvas = forwardRef((_props, ref) => { +export const ComposeCanvas = forwardRef< + CanvasTools, + { readOnly?: boolean; initialData?: any } +>(({ readOnly = false, initialData = null }, ref) => { const wrapperRef = useRef(null); const canvasRef = useRef(null); const fabricRef = useRef(null); @@ -51,7 +54,7 @@ export const ComposeCanvas = forwardRef((_props, ref) => { canvas = new fabric.Canvas(canvasRef.current, { width: finalWidth, height: initialHeight, - selection: false, + selection: !readOnly, preserveObjectStacking: true, allowTouchScrolling: true, }); @@ -61,65 +64,76 @@ export const ComposeCanvas = forwardRef((_props, ref) => { const wrapperEl = canvas.getElement().parentElement; if (wrapperEl) wrapperEl.style.background = "transparent"; - const textbox = new fabric.Textbox("Take a deep breath...", { - name: "main-textbox", - originX: "left", - originY: "top", - left: PAD, - top: PAD, - width: finalWidth - PAD * 2, - fontSize: 16, - fontWeight: 500, - fontFamily: "Playfair Display Variable", - fill: "#000", - lineHeight: 1.5, - editable: true, - hasControls: false, - hasBorders: false, - objectCaching: false, - splitByGrapheme: false, - lockMovementX: true, - lockMovementY: true, - lockScalingX: true, - lockScalingY: true, - }); - - textboxRef.current = textbox; - canvas.add(textbox); - - textbox.on("changed", () => { - if (!canvas || !wrapperRef.current) return; - const neededHeight = textbox.top + textbox.height + PAD; - if (neededHeight > canvas.height) { - const newH = neededHeight + PAD; - canvas.setDimensions({ height: newH }); - wrapperRef.current.style.height = `${newH}px`; + if (initialData) { + await canvas.loadFromJSON(initialData); + if (readOnly) { + canvas.getObjects().forEach((obj) => { + obj.selectable = false; + obj.evented = false; + }); } - }); + canvas.renderAll(); + } else { + const textbox = new fabric.Textbox("Take a deep breath...", { + name: "main-textbox", + originX: "left", + originY: "top", + left: PAD, + top: PAD, + width: finalWidth - PAD * 2, + fontSize: 16, + fontWeight: 500, + fontFamily: "Playfair Display Variable", + fill: "#000", + lineHeight: 1.5, + editable: true, + hasControls: false, + hasBorders: false, + objectCaching: false, + splitByGrapheme: false, + lockMovementX: true, + lockMovementY: true, + lockScalingX: true, + lockScalingY: true, + }); - setTimeout(() => { - if (!isMounted) return; - canvas?.setActiveObject(textbox); - textbox.enterEditing(); - canvas?.renderAll(); + textboxRef.current = textbox; + canvas.add(textbox); - const hiddenTextareas = document.querySelectorAll( - 'textarea[data-fabric="textarea"]', - ); - hiddenTextareas.forEach((ta) => { - if (!ta.getAttribute("aria-label")) { - ta.setAttribute("aria-label", "Canvas text input"); + textbox.on("changed", () => { + if (!canvas || !wrapperRef.current) return; + const neededHeight = textbox.top + textbox.height + PAD; + if (neededHeight > canvas.height) { + const newH = neededHeight + PAD; + canvas.setDimensions({ height: newH }); + wrapperRef.current.style.height = `${newH}px`; } }); - }, 100); - canvas.on("mouse:down", (opt) => { - if (!opt.target || opt.target === textbox) { + setTimeout(() => { + if (!isMounted) return; canvas?.setActiveObject(textbox); textbox.enterEditing(); canvas?.renderAll(); - } - }); + + const hiddenTextareas = document.querySelectorAll( + 'textarea[data-fabric="textarea"]', + ); + hiddenTextareas.forEach((ta) => { + if (!ta.getAttribute("aria-label")) { + ta.setAttribute("aria-label", "Canvas text input"); + } + }); + }, 100); + + canvas.on("mouse:down", (opt) => { + if (!opt.target || opt.target === textbox) { + canvas?.setActiveObject(textbox); + textbox.enterEditing(); + canvas?.renderAll(); + } + }); + } }; init(); @@ -130,7 +144,7 @@ export const ComposeCanvas = forwardRef((_props, ref) => { fabricRef.current = null; textboxRef.current = null; }; - }, []); + }, [initialData, readOnly]); useImperativeHandle(ref, () => ({ addImage: (url: string, file: File) => { diff --git a/frontend/src/hooks/useAuth.test.ts b/frontend/src/hooks/useAuth.test.ts index 82a3c5f..1bf44b8 100644 --- a/frontend/src/hooks/useAuth.test.ts +++ b/frontend/src/hooks/useAuth.test.ts @@ -1,14 +1,6 @@ import { act, renderHook } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { mockUser } from "../../test/fixtures/user.fixture"; import { server } from "../../test/mocks/server"; import { useAuthStore } from "../store/useAuthStore"; @@ -39,14 +31,6 @@ vi.mock("../utils/keystore", () => ({ clearMasterKey: vi.fn().mockResolvedValue(undefined), })); -beforeAll(() => { - vi.stubEnv("API_URL", API_URL); -}); - -afterAll(() => { - vi.unstubAllEnvs(); -}); - beforeEach(() => { vi.clearAllMocks(); useAuthStore.setState({ @@ -58,13 +42,13 @@ beforeEach(() => { }); describe("isAuthenticated", () => { - it("should be false when access token is not present in the store", () => { + it("should be false when the access token is missing from the store", () => { const { result } = renderHook(() => useAuth()); expect(result.current.isAuthenticated).toBe(false); }); - it("should be true when access token is present in the store", () => { + it("should be true when the access token is present in the store", () => { useAuthStore.setState({ accessToken: "token", user: mockUser, @@ -77,7 +61,7 @@ describe("isAuthenticated", () => { }); describe("login", () => { - it("should derive the master key using the provided password and email (salt)", async () => { + it("should derive the master key using the provided credentials", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { @@ -100,7 +84,7 @@ describe("login", () => { expect(saveMasterKey).toHaveBeenCalledTimes(1); }); - it("should set the auth store with the provided access token and user profile", async () => { + it("should update the store with the access token and user profile", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { @@ -147,7 +131,7 @@ describe("logout", () => { expect(logoutCalled).toBe(true); }); - it("should clear the master key from both the key store and IndexedDB", async () => { + it("should clear the master key from the store and IndexedDB", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { @@ -158,7 +142,7 @@ describe("logout", () => { expect(clearMasterKey).toHaveBeenCalledTimes(1); }); - it("should clear auth store (access token + user) and master key even if API fails", async () => { + it("should clear the auth store even if the API call fails", async () => { server.use( http.post( `${API_URL}/api/auth/logout/`, @@ -200,7 +184,7 @@ describe("initialize", () => { expect(useAuthStore.getState().isInitializing).toBe(false); }); - it("should call /refresh restore master key from IndexedDB when session not in memory", async () => { + it("should call /refresh and restore the master key when the session is empty", async () => { const { result } = renderHook(() => useAuth()); await act(async () => { @@ -213,7 +197,7 @@ describe("initialize", () => { expect(useKeyStore.getState().masterKey).not.toBeNull(); }); - it("should clear auth + key store when refresh fails", async () => { + it("should clear both stores if the refresh attempt fails", async () => { server.use( http.post( `${API_URL}/api/auth/refresh/`, diff --git a/frontend/src/pages/Drawer.test.tsx b/frontend/src/pages/Drawer.test.tsx index 9620817..414c9e3 100644 --- a/frontend/src/pages/Drawer.test.tsx +++ b/frontend/src/pages/Drawer.test.tsx @@ -7,7 +7,7 @@ import Drawer from "./Drawer"; describe("Drawer Page", () => { beforeEach(() => { - // Setup authenticated state + // Setup authenticated state for the test useAuthStore.setState({ user: mockUser, accessToken: "fake-token", diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index a72262c..91ef70e 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -102,6 +102,11 @@ export default function Drawer() { timestamp={letter.updated_at} /> ))} + {sent.length === 0 && ( +

+ This drawer remains silent +

+ )} { - const response = await fetch(blobUrl); - const blob = await response.blob(); - return new File([blob], fileName, { type: mimeType ?? blob.type }); -} +import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; export default function Editor() { const navigate = useNavigate(); @@ -37,6 +27,7 @@ export default function Editor() { const [isInitialLoading, setIsInitialLoading] = useState(false); const [isSealing, setIsSealing] = useState(false); const [isSaveSuccess, setIsSaveSuccess] = useState(false); + const [shareLink, setShareLink] = useState(null); const [recipient, setRecipient] = useState(""); const { masterKey } = useKeyStore(); @@ -50,13 +41,13 @@ export default function Editor() { const loadExistingLetter = async () => { setIsInitialLoading(true); - const cryptoUtils = new CryptoUtils(); + const crypto = new CryptoUtils(); try { const res = await api.get(`${endpoints.LETTERS}${public_id}/`); const letterData = res.data; - // metadata for recipient - const metadata = await cryptoUtils.decryptMetadata( + // Decrypt the metadata (for the recipient field) + const metadata = await crypto.decryptMetadata( { encrypted_content: letterData.encrypted_metadata, encrypted_dek: letterData.encrypted_dek, @@ -65,8 +56,8 @@ export default function Editor() { ); setRecipient(metadata.recipient || ""); - // decrypt canvas data - const decryptedJsonStr = await cryptoUtils.decryptLetter( + // Decrypt the main canvas JSON + const decryptedJsonStr = await crypto.decryptLetter( { encrypted_content: letterData.encrypted_content, encrypted_dek: letterData.encrypted_dek, @@ -75,45 +66,16 @@ export default function Editor() { ); const canvasData = JSON.parse(decryptedJsonStr); - // traverse through canvas images and replace encrypted image with decrypted image - if (canvasData.objects) { - for (const obj of canvasData.objects) { - if (obj.type === "Image" && typeof obj.src === "string") { - const filename = obj.src; - const remoteImage = letterData.images.find( - (img: any) => img.file_name === filename, - ); + // Batch decrypt images within the canvas + await decryptCanvasImages( + canvasData, + letterData.images, + letterData.encrypted_dek, + masterKey, + true, // restore raw files for the editor + ); - if (remoteImage) { - try { - // fetch encrypted image blob using authenticated API - const imageRes = await api.get(remoteImage.file, { - responseType: "blob", - }); - const encryptedBlob = imageRes.data; - - // decrypt image blob - const blobUrl = await cryptoUtils.decryptImage( - encryptedBlob, - letterData.encrypted_dek, - masterKey, - ); - obj.src = blobUrl; - obj._customRawFile = await blobUrlToFile(blobUrl, filename); - console.log("Decrypted image object:", obj); - } catch (imgErr) { - console.error( - "Failed to decrypt image object:", - filename, - imgErr, - ); - } - } - } - } - } - - // load updated data into canvas + // Load data into the Fabric canvas requestAnimationFrame(() => { canvasRef.current?.loadData(canvasData); }); @@ -129,7 +91,7 @@ export default function Editor() { // -------------------------------------------------------------------------------------- const handleImageUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; // pick one file at a time + const file = e.target.files?.[0]; if (file) { const url = URL.createObjectURL(file); canvasRef.current?.addImage(url, file); @@ -138,88 +100,80 @@ export default function Editor() { const handleSave = async (status: "SEALED" | "DRAFT"): Promise => { if (!public_id && !letterIdRef.current) { - // if no uuid slug, then generate a new one and update params letterIdRef.current = crypto.randomUUID(); navigate(PATHS.write(letterIdRef.current), { replace: true }); } else if (public_id) { letterIdRef.current = public_id; } - if (isSealing) return; + if (isSealing || !masterKey) return; setIsSealing(true); + const cryptoUtils = new CryptoUtils(); await cryptoUtils.initialize(); - const images = canvasRef.current?.getImages() || []; - const imageEncMap = new Map(); - const encImageFilesMap = new Map(); - - if (!masterKey) { - throw new Error("Master key is not initialized"); - } - - for (const image of images) { - if (image.src.endsWith(".bin")) continue; - try { - const encrypted_image = await cryptoUtils.encryptImage( - image.file, - masterKey, - ); - imageEncMap.set(image.src, encrypted_image.filename); - encImageFilesMap.set( - encrypted_image.filename, - encrypted_image.encryptedBlob, - ); - } catch (err) { - console.error("Failed to re-encrypt image:", err); - } - } - - // replace image src with encrypted image filename - const canvasData = canvasRef.current?.getData(); - if (canvasData?.objects) { - canvasData.objects = canvasData.objects.map((obj: any) => { - if (obj.type === "Image" && imageEncMap.has(obj.src)) { - return { ...obj, src: imageEncMap.get(obj.src) }; - } - return obj; - }); - } - - const encrypted_letter = await cryptoUtils.encryptLetter( - JSON.stringify(canvasData), - masterKey, - ); - - const encrypted_metadata = await cryptoUtils.encryptMetadata( - { recipient, tags: [] }, - masterKey, - ); - - const formData = new FormData(); - formData.append("public_id", letterIdRef.current); - formData.append("type", "KEPT"); - formData.append("status", status); - formData.append("encrypted_content", encrypted_letter.encrypted_content); - formData.append("encrypted_dek", encrypted_letter.encrypted_dek); - formData.append("encrypted_metadata", encrypted_metadata.encrypted_content); - encImageFilesMap.forEach((image, filename) => { - formData.append("image_files", image, filename); - }); - try { + const canvasData = canvasRef.current?.getData(); + const canvasImages = canvasRef.current?.getImages() || []; + + // Secure any new images first + const encImageFilesMap = await encryptCanvasImages( + canvasData, + canvasImages, + masterKey, + ); + + // Encrypt the updated canvas JSON + const encrypted_letter = await cryptoUtils.encryptLetter( + JSON.stringify(canvasData), + masterKey, + ); + + const encrypted_metadata = await cryptoUtils.encryptMetadata( + { recipient, tags: [] }, + masterKey, + ); + + const formData = new FormData(); + formData.append("public_id", letterIdRef.current); + formData.append("type", "KEPT"); + formData.append("status", status); + formData.append("encrypted_content", encrypted_letter.encrypted_content); + formData.append("encrypted_dek", encrypted_letter.encrypted_dek); + formData.append( + "encrypted_metadata", + encrypted_metadata.encrypted_content, + ); + + encImageFilesMap.forEach((blob, filename) => { + formData.append("image_files", blob, filename); + }); + await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData); setIsSaveSuccess(true); - setTimeout(() => { - setIsSaveSuccess(false); - }, 5000); + + if (status === "SEALED" && encrypted_letter.sharingKey) { + const link = `${window.location.origin}${PATHS.read(letterIdRef.current)}#${encrypted_letter.sharingKey}`; + setShareLink(link); + } + + setTimeout(() => setIsSaveSuccess(false), 5000); } catch (error) { - console.error("Error sealing letter:", error); + console.error("Save failed:", error); } finally { setIsSealing(false); } }; + const copyToClipboard = async () => { + if (!shareLink) return; + try { + await navigator.clipboard.writeText(shareLink); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + return (
{isInitialLoading && ( @@ -236,7 +190,53 @@ export default function Editor() { )} - {isSaveSuccess && ( + {/* Sharing Modal */} + {shareLink && ( +
+
+ +
+
+ +
+
+

Sealed & Ready

+

+ This letter is now encrypted. Share this secret link with your + recipient. +

+
+ +
+ + +
+ +

+ Zero-Knowledge: The key is in the link, not our servers. +

+
+
+
+ )} + + {isSaveSuccess && !shareLink && (
{ + afterEach(() => { + server.resetHandlers(); + }); + + it("should render the sign-in form correctly", () => { + render( + + + , + ); + + expect(screen.getByText("Sign in to")).toBeInTheDocument(); + }); + + it("should display a technical issues message when the server is down", async () => { + server.use( + http.post(`${API_URL}${endpoints.LOGIN}`, () => + HttpResponse.json({ detail: "Internal Server Error" }, { status: 500 }), + ), + ); + + render( + + + , + ); + + await userEvent.type(screen.getByLabelText(/email/i), "test@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "password123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText(/technical issues/i)).toBeInTheDocument(); + }); + + it("should redirect to the drawer when login is successful", async () => { + const mockUser = { + public_id: "user-123", + email: "test@example.com", + full_name: "Test User", + }; + + server.use( + http.post(`${API_URL}${endpoints.LOGIN}`, () => + HttpResponse.json({ access: "fake-token" }), + ), + ); + server.use( + http.get(`${API_URL}${endpoints.ME}`, () => HttpResponse.json(mockUser)), + ); + + render( + + + } /> + Drawer
} /> + + , + ); + + await userEvent.type(screen.getByLabelText(/email/i), "test@example.com"); + await userEvent.type(screen.getByLabelText(/password/i), "password123"); + await userEvent.click(screen.getByRole("button", { name: /sign in/i })); + + expect(await screen.findByText(/Drawer/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Reader.test.tsx b/frontend/src/pages/Reader.test.tsx new file mode 100644 index 0000000..975fa45 --- /dev/null +++ b/frontend/src/pages/Reader.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import { HttpResponse, http } from "msw"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { server } from "../../test/mocks/server"; +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 +const spyDecryptLetter = vi.spyOn( + CryptoUtils.prototype, + "decryptLetterWithSharingKey", +); +const spyDecryptMetadata = vi.spyOn( + CryptoUtils.prototype, + "decryptMetadataWithSharingKey", +); +const spyDecryptImage = vi.spyOn( + CryptoUtils.prototype, + "decryptImageWithSharingKey", +); + +// Fabric.js needs to know when fonts are loaded +Object.defineProperty(document, "fonts", { + value: { ready: Promise.resolve() }, + configurable: true, +}); + +describe("Reader Page", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock behavior for successful decryption + spyDecryptLetter.mockResolvedValue('{"objects": []}'); + spyDecryptMetadata.mockResolvedValue({ recipient: "Guest" }); + spyDecryptImage.mockResolvedValue("blob:url"); + + // Clear the URL hash + vi.stubGlobal("location", { + hash: "", + href: "http://localhost/", + }); + }); + + it("should notify the user if the sharing key is missing from the URL", async () => { + render( + + + } /> + + , + ); + + expect( + await screen.findByText(/No sharing key provided/i), + ).toBeInTheDocument(); + }); + + 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({ + encrypted_content: "packed-content", + encrypted_metadata: "packed-metadata", + images: [], + }); + }), + ); + + render( + + + } /> + + , + ); + + // 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), + ).toBeInTheDocument(); + }); + + it("should display an error message if the server request fails", async () => { + const mockPublicId = "fail-uuid"; + const mockKey = "some-key"; + + server.use( + http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + render( + + + } /> + + , + ); + + expect( + await screen.findByText(/Failed to load letter/i), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Reader.tsx b/frontend/src/pages/Reader.tsx new file mode 100644 index 0000000..e8eb543 --- /dev/null +++ b/frontend/src/pages/Reader.tsx @@ -0,0 +1,103 @@ +import { CrossIcon } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useLocation, useParams } from "react-router-dom"; +import { api } from "../api/apiClient"; +import { ComposeCanvas } from "../components/ui/ComposeCanvas"; +import { endpoints } from "../config/endpoints"; +import { CryptoUtils } from "../utils/crypto"; +import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; + +export default function Reader() { + const { public_id } = useParams(); + const location = useLocation(); + const sharingKey = location.hash.replace("#", ""); + const [isDecrypting, setIsDecrypting] = useState(true); + const [error, setError] = useState(null); + const [canvasData, setCanvasData] = useState(null); + const [metadata, setMetadata] = useState(null); + + useEffect(() => { + if (!sharingKey) { + setError("No sharing key provided. Please check the link."); + setIsDecrypting(false); + return; + } + + const loadAndDecrypt = async () => { + try { + const response = await api.get(`${endpoints.LETTERS}${public_id}/`); + const { encrypted_content, encrypted_metadata, images } = response.data; + + const crypto = new CryptoUtils(); + + // 1. Decrypt metadata using the sharing key from the URL + const decryptedMetadata = await crypto.decryptMetadataWithSharingKey( + encrypted_metadata, + sharingKey, + ); + setMetadata(decryptedMetadata); + + // 2. Decrypt the main letter content + const decryptedContent = await crypto.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); + } + + setCanvasData(json); + setIsDecrypting(false); + } catch (err: any) { + console.error("Reader Error:", err); + setError(`Failed to load letter: ${err.message || "Unknown error"}`); + setIsDecrypting(false); + } + }; + + loadAndDecrypt(); + }, [public_id, sharingKey]); + + if (isDecrypting) { + return ( +
+ +

Decrypting...

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

+ A sealed message for {metadata.recipient} +

+
+ )} + {canvasData && } +
+ ); +} diff --git a/frontend/src/utils/crypto.test.ts b/frontend/src/utils/crypto.test.ts index e8d6369..fa7300f 100644 --- a/frontend/src/utils/crypto.test.ts +++ b/frontend/src/utils/crypto.test.ts @@ -116,4 +116,41 @@ describe("encryptImage / decryptImage", () => { expect(result.filename).not.toMatch(/photo|jpg/); expect(encryptedText).not.toContain("image-data"); }); + + it("should support decryption using a sharing key (guest access)", async () => { + const rawData = new TextEncoder().encode("image-data"); + const file = new File([rawData], "photo.jpg", { type: "image/jpeg" }); + + const result = await utils.encryptImage(file, masterKey); + const encryptedLetter = await utils.encryptLetter("test", masterKey); + const sharingKey = encryptedLetter.sharingKey; + + const blobUrl = await utils.decryptImageWithSharingKey( + result.encryptedBlob, + sharingKey, + ); + + expect(blobUrl).toContain("blob:"); + URL.revokeObjectURL(blobUrl); // cleanup + }); +}); + +describe("Sharing Key Decryption (TDD)", () => { + let masterKey: CryptoKey; + beforeEach(async () => { + masterKey = await CryptoUtils.deriveMasterKey("pass", "salt"); + }); + + it("should decrypt a letter using ONLY the sharing key", async () => { + const letterContent = "hello, guest"; + + const encryptedLetter = await utils.encryptLetter(letterContent, masterKey); + const sharingKey = encryptedLetter.sharingKey; + const decryptedLetter = await utils.decryptLetterWithSharingKey( + encryptedLetter.encrypted_content, + sharingKey, + ); + + expect(decryptedLetter).toBe(letterContent); + }); }); diff --git a/frontend/src/utils/crypto.ts b/frontend/src/utils/crypto.ts index e1a7976..5998084 100644 --- a/frontend/src/utils/crypto.ts +++ b/frontend/src/utils/crypto.ts @@ -19,9 +19,6 @@ export interface EncryptedImageUpload { filename: string; } -/* - * Wrapper functions - */ interface SealedEnvelope { encryptedContent: string; encrypted_dek: string; @@ -33,6 +30,7 @@ export class CryptoUtils { private static readonly PBKDF2_ITERATIONS = 100_000; private static readonly AES_GCM = { name: "AES-GCM", length: 256 }; + // Generates a fresh Data Encryption Key (DEK) async initialize() { this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [ "encrypt", @@ -44,7 +42,6 @@ export class CryptoUtils { toBase64 = (buf: Uint8Array): string => btoa(buf.reduce((s, b) => s + String.fromCharCode(b), "")); - // explicit loop ensures Uint8Array (not ArrayBufferLike) fromBase64 = (b64: string): Uint8Array => { const str = atob(b64); const arr = new Uint8Array(str.length); @@ -60,24 +57,22 @@ export class CryptoUtils { return this.toBase64(packed); }; - // split IV (first 12 bytes) back out from a packed base64 bundle unpackWithIv = ( b64: string, ): [Uint8Array, Uint8Array] => { - const buf = this.fromBase64(b64); // ArrayBuffer-backed, so buf.buffer is ArrayBuffer + const buf = this.fromBase64(b64); return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)]; }; /** - * Derives a Master Key from a password and email (salt). - * Deterministic — same credentials always produce the same key. + * Derives a Master Key from a password + email (salt). + * Same credentials = same key. */ public static async deriveMasterKey( password: string, email: string, ): Promise { const enc = new TextEncoder(); - const baseKey = await crypto.subtle.importKey( "raw", enc.encode(password), @@ -100,11 +95,11 @@ export class CryptoUtils { ); } + // Internal helper to encrypt data and wrap the key private async sealEnvelope( input: Uint8Array, masterKey: CryptoKey, ): Promise { - // copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays const plainBytes = new Uint8Array(input); // encrypt the content with the DEK @@ -132,12 +127,12 @@ export class CryptoUtils { }; } + // Internal helper to unwrap the key and decrypt data private async openEnvelope( encryptedContent: string, encrypted_dek: string, masterKey: CryptoKey, ): Promise> { - // unwrap the DEK using the master key const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek); const dek = await crypto.subtle.unwrapKey( "raw", @@ -149,7 +144,29 @@ export class CryptoUtils { ["decrypt"], ); - // decrypt the content with the recovered DEK + const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); + const plainBytes = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: contentIv }, + dek, + ciphertext, + ); + + return new Uint8Array(plainBytes); + } + + private async openEnvelopeWithSharingKey( + encryptedContent: string, + sharingKey: string, + ): Promise> { + const dekBytes = this.fromBase64(sharingKey); + const dek = await crypto.subtle.importKey( + "raw", + dekBytes, + CryptoUtils.AES_GCM, + false, + ["decrypt"], + ); + const [contentIv, ciphertext] = this.unpackWithIv(encryptedContent); const plainBytes = await crypto.subtle.decrypt( { name: "AES-GCM", iv: contentIv }, @@ -169,7 +186,6 @@ export class CryptoUtils { ): Promise { const { encryptedContent, encrypted_dek, sharingKey } = await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey); - return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; } @@ -177,17 +193,25 @@ export class CryptoUtils { { encrypted_content, encrypted_dek }: EncryptedLetter, masterKey: CryptoKey, ): Promise { - const plainBytes = await this.openEnvelope( + const bytes = await this.openEnvelope( encrypted_content, encrypted_dek, masterKey, ); - return new TextDecoder().decode(plainBytes); + return new TextDecoder().decode(bytes); + } + + public async decryptLetterWithSharingKey( + encrypted_content: string, + sharingKey: string, + ): Promise { + const bytes = await this.openEnvelopeWithSharingKey( + encrypted_content, + sharingKey, + ); + return new TextDecoder().decode(bytes); } - /* - * Metadata functions - */ public async encryptMetadata( metadata: Record, masterKey: CryptoKey, @@ -197,7 +221,6 @@ export class CryptoUtils { new TextEncoder().encode(JSON.stringify(metadata)), masterKey, ); - return { encrypted_content: encryptedContent, encrypted_dek, sharingKey }; } @@ -205,17 +228,25 @@ export class CryptoUtils { encrypted_metadata: EncryptedLetter, masterKey: CryptoKey, ): Promise> { - const plainBytes = await this.openEnvelope( + const bytes = await this.openEnvelope( encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_dek, masterKey, ); - return JSON.parse(new TextDecoder().decode(plainBytes)); + return JSON.parse(new TextDecoder().decode(bytes)); + } + + public async decryptMetadataWithSharingKey( + encrypted_content: string, + sharingKey: string, + ): Promise> { + const bytes = await this.openEnvelopeWithSharingKey( + encrypted_content, + sharingKey, + ); + return JSON.parse(new TextDecoder().decode(bytes)); } - /* - * Image functions - */ public async encryptImage( file: File, masterKey: CryptoKey, @@ -239,13 +270,23 @@ export class CryptoUtils { masterKey: CryptoKey, ): Promise { const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); - const plainBytes = await this.openEnvelope( + const bytes = await this.openEnvelope( this.toBase64(encryptedBytes), encrypted_dek, masterKey, ); + return URL.createObjectURL(new Blob([bytes])); + } - // return as object URL for use in Fabric / - return URL.createObjectURL(new Blob([plainBytes])); + public async decryptImageWithSharingKey( + encryptedBlob: Blob, + sharingKey: string, + ): Promise { + const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); + const bytes = await this.openEnvelopeWithSharingKey( + this.toBase64(encryptedBytes), + sharingKey, + ); + return URL.createObjectURL(new Blob([bytes])); } } diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts new file mode 100644 index 0000000..3627c26 --- /dev/null +++ b/frontend/src/utils/fileUtils.ts @@ -0,0 +1,17 @@ +/** + * Common utilities for handling files and blobs in the browser. + */ + +/** + * Converts a blob URL (like blob:http://...) back into a File object. + * We use this to restore images on the canvas when saving a draft. + */ +export async function blobUrlToFile( + blobUrl: string, + fileName: string, + mimeType?: string, +): Promise { + const response = await fetch(blobUrl); + const blob = await response.blob(); + return new File([blob], fileName, { type: mimeType ?? blob.type }); +} diff --git a/frontend/src/utils/letterLogic.ts b/frontend/src/utils/letterLogic.ts new file mode 100644 index 0000000..dfef295 --- /dev/null +++ b/frontend/src/utils/letterLogic.ts @@ -0,0 +1,115 @@ +import { api } from "../api/apiClient"; +import { CryptoUtils } from "./crypto"; +import { blobUrlToFile } from "./fileUtils"; + +// Helpers to handle the complex process of locking and unlocking letters with images. + +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, + includeRawFile = false, +) { + if (!canvasData?.objects) return; + + const imageMap = new Map( + remoteImages.map((img) => [img.file_name, img.file]), + ); + + for (const obj of canvasData.objects) { + if (obj.type === "Image" && typeof obj.src === "string") { + const remoteUrl = imageMap.get(obj.src); + if (!remoteUrl) continue; + + try { + const res = await api.get(remoteUrl, { responseType: "blob" }); + const blobUrl = await crypto.decryptImage( + res.data, + encrypted_dek, + masterKey, + ); + + 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) { + console.error("Error decrypting image in canvas:", obj.src, err); + } + } + } +} + +// Decrypts canvas images using just the sharing key (for guest access). +export async function decryptCanvasImagesWithSharingKey( + canvasData: any, + remoteImages: any[], + sharingKey: string, +) { + if (!canvasData?.objects) return; + + const imageMap = new Map( + remoteImages.map((img) => [img.file_name, img.file]), + ); + + for (const obj of canvasData.objects) { + if (obj.type === "Image" && typeof obj.src === "string") { + const remoteUrl = imageMap.get(obj.src); + if (!remoteUrl) continue; + + try { + const res = await api.get(remoteUrl, { responseType: "blob" }); + obj.src = await crypto.decryptImageWithSharingKey(res.data, sharingKey); + } catch (err) { + console.error("Guest decryption failed for canvas image:", err); + } + } + } +} + +// 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 }[], + masterKey: CryptoKey, +) { + 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; + + try { + const { filename, encryptedBlob } = await crypto.encryptImage( + img.file, + masterKey, + ); + filenameMapping.set(img.src, filename); + encryptedFiles.set(filename, encryptedBlob); + } catch (err) { + console.error("Failed to encrypt new canvas image:", 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)) { + return { ...obj, src: filenameMapping.get(obj.src) }; + } + return obj; + }); + } + + return encryptedFiles; +}