refactor: move DrawerSection component and implement image encryption/decryption tests

This commit is contained in:
ramvignesh-b
2026-04-14 00:03:56 +05:30
parent b64288c5dd
commit 1f94df1309
9 changed files with 322 additions and 109 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ describe("ProtectedRoute", () => {
"/protected", "/protected",
); );
expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.queryByText("Secret")).not.toBeInTheDocument(); expect(screen.queryByText("Secret")).not.toBeInTheDocument();
}); });
@@ -90,7 +90,7 @@ describe("PublicRoute", () => {
</PublicRoute>, </PublicRoute>,
"/public", "/public",
); );
expect(screen.getByText(/Initializing Identity/i)).toBeInTheDocument(); expect(screen.getByText(/Unsealing.../i)).toBeInTheDocument();
expect(screen.queryByText("Login Page")).not.toBeInTheDocument(); expect(screen.queryByText("Login Page")).not.toBeInTheDocument();
}); });
+2 -2
View File
@@ -7,8 +7,8 @@ export default function SplashScreen() {
<Logo /> <Logo />
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="loading loading-ring loading-lg text-primary" /> <span className="loading loading-ring loading-lg text-primary" />
<p className="text-xs uppercase tracking-widest opacity-40 font-display"> <p className="text-xs uppercase font-sans tracking-widest opacity-40">
Initializing Identity Unsealing...
</p> </p>
</div> </div>
</div> </div>
+2 -2
View File
@@ -2,7 +2,7 @@ import { FeatherIcon } from "@phosphor-icons/react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import Logo from "../components/Logo"; import Logo from "../components/Logo";
import { DrawerSection } from "../components/ui/Drawer"; import { DrawerSection } from "../components/ui/DrawerSection";
import { LetterItem } from "../components/ui/LetterItem"; import { LetterItem } from "../components/ui/LetterItem";
import { PATHS } from "../config/routes"; import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
@@ -10,7 +10,7 @@ import { useLetters } from "../hooks/useLetters";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [openSection, setOpenSection] = useState<string | null>("kept"); const [openSection, setOpenSection] = useState<string | null>();
const navigate = useNavigate(); const navigate = useNavigate();
const { drafts, kept, sent, vault, loading } = useLetters(); const { drafts, kept, sent, vault, loading } = useLetters();
+13 -21
View File
@@ -35,19 +35,18 @@ export default function Editor() {
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Initial load: Fetch and decrypt existing letter
useEffect(() => { useEffect(() => {
if (!public_id || !masterKey) return; if (!public_id || !masterKey) return;
const loadExistingLetter = async () => { const loadExistingLetter = async () => {
setIsInitialLoading(true); setIsInitialLoading(true);
const crypto = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
try { try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`); const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data; const letterData = res.data;
// Decrypt the metadata (for the recipient field) const metadata = await cryptoUtils.decryptMetadata(
const metadata = await crypto.decryptMetadata(
{ {
encrypted_content: letterData.encrypted_metadata, encrypted_content: letterData.encrypted_metadata,
encrypted_dek: letterData.encrypted_dek, encrypted_dek: letterData.encrypted_dek,
@@ -56,8 +55,7 @@ export default function Editor() {
); );
setRecipient(metadata.recipient || ""); setRecipient(metadata.recipient || "");
// Decrypt the main canvas JSON const decryptedJsonStr = await cryptoUtils.decryptLetter(
const decryptedJsonStr = await crypto.decryptLetter(
{ {
encrypted_content: letterData.encrypted_content, encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek, encrypted_dek: letterData.encrypted_dek,
@@ -66,16 +64,15 @@ export default function Editor() {
); );
const canvasData = JSON.parse(decryptedJsonStr); const canvasData = JSON.parse(decryptedJsonStr);
// Batch decrypt images within the canvas
await decryptCanvasImages( await decryptCanvasImages(
canvasData, canvasData,
letterData.images, letterData.images ?? [],
letterData.encrypted_dek, letterData.encrypted_dek,
masterKey, masterKey,
true, // restore raw files for the editor cryptoUtils,
true,
); );
// Load data into the Fabric canvas
requestAnimationFrame(() => { requestAnimationFrame(() => {
canvasRef.current?.loadData(canvasData); canvasRef.current?.loadData(canvasData);
}); });
@@ -115,14 +112,13 @@ export default function Editor() {
const canvasData = canvasRef.current?.getData(); const canvasData = canvasRef.current?.getData();
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
// Secure any new images first
const encImageFilesMap = await encryptCanvasImages( const encImageFilesMap = await encryptCanvasImages(
canvasData, canvasData,
canvasImages, canvasImages,
masterKey, masterKey,
cryptoUtils,
); );
// Encrypt the updated canvas JSON
const encrypted_letter = await cryptoUtils.encryptLetter( const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(canvasData), JSON.stringify(canvasData),
masterKey, masterKey,
@@ -186,7 +182,7 @@ export default function Editor() {
</div> </div>
</div> </div>
)} )}
{/* Sharing Modal */}
{shareLink && ( {shareLink && (
<div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-[100]"> <div className="modal modal-open modal-bottom sm:modal-middle bg-base-100/20 backdrop-blur-md z-[100]">
<div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative"> <div className="modal-box bg-base-100 border border-base-content/5 shadow-2xl relative">
@@ -233,10 +229,7 @@ export default function Editor() {
)} )}
{isSaveSuccess && !shareLink && ( {isSaveSuccess && !shareLink && (
<div <div className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out animate-fade-in opacity-80">
className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out
animate-fade-in opacity-80"
>
<div className="alert alert-success opacity-90"> <div className="alert alert-success opacity-90">
<DownloadSimpleIcon size={18} weight="bold" /> <DownloadSimpleIcon size={18} weight="bold" />
<h3 className="font-bold text-lg text-success-content"> <h3 className="font-bold text-lg text-success-content">
@@ -245,11 +238,9 @@ export default function Editor() {
</div> </div>
</div> </div>
)} )}
{isSealing && ( {isSealing && (
<div <div className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out animate-fade-in opacity-80">
className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out
animate-fade-in opacity-80"
>
<div className="alert alert-neutral"> <div className="alert alert-neutral">
<SpinnerGapIcon size={18} weight="bold" className="animate-spin" /> <SpinnerGapIcon size={18} weight="bold" className="animate-spin" />
<h3 className="font-bold text-neutral-content text-lg animate-pulse"> <h3 className="font-bold text-neutral-content text-lg animate-pulse">
@@ -258,6 +249,7 @@ export default function Editor() {
</div> </div>
</div> </div>
)} )}
<div className="max-w-180 mx-auto px-1 md:px-0"> <div className="max-w-180 mx-auto px-1 md:px-0">
<div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0"> <div className="flex justify-between items-end mb-16 border-b border-base-content/5 pb-8 px-0">
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
+1 -5
View File
@@ -7,7 +7,6 @@ import { endpoints } from "../config/endpoints";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import Reader from "./Reader"; import Reader from "./Reader";
// We use the same API_URL logic as our other tests
const API_URL = import.meta.env.VITE_API_URL; 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 // 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 () => { it("should load and decrypt the letter when a valid key is provided", async () => {
const mockPublicId = "test-uuid"; const mockPublicId = "test-uuid";
const mockKey = "fake-key"; const mockKey = "fake-key";
// Mock the server response using MSW
server.use( server.use(
http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => { http.get(`${API_URL}${endpoints.LETTERS}${mockPublicId}/`, () => {
return HttpResponse.json({ return HttpResponse.json({
@@ -86,9 +83,8 @@ describe("Reader Page", () => {
// Should show loading state first // Should show loading state first
expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument(); expect(screen.getByText(/Decrypting.../i)).toBeInTheDocument();
// Eventually should show the decrypted recipient header
expect( expect(
await screen.findByText(/A sealed message for Guest/i), await screen.findByText(/A sealed message for/i),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
+70 -38
View File
@@ -1,8 +1,11 @@
import { CrossIcon } from "@phosphor-icons/react"; 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 { useLocation, useParams } from "react-router-dom";
import { api } from "../api/apiClient"; 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 { endpoints } from "../config/endpoints";
import { CryptoUtils } from "../utils/crypto"; import { CryptoUtils } from "../utils/crypto";
import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic"; import { decryptCanvasImagesWithSharingKey } from "../utils/letterLogic";
@@ -11,10 +14,13 @@ export default function Reader() {
const { public_id } = useParams(); const { public_id } = useParams();
const location = useLocation(); const location = useLocation();
const sharingKey = location.hash.replace("#", ""); const sharingKey = location.hash.replace("#", "");
const canvasRef = useRef<CanvasTools>(null);
const [isDecrypting, setIsDecrypting] = useState(true); const [isDecrypting, setIsDecrypting] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [canvasData, setCanvasData] = useState<any>(null);
const [metadata, setMetadata] = useState<any>(null); const [metadata, setMetadata] = useState<any>(null);
const [decryptedCanvasData, setDecryptedCanvasData] = useState<any>(null);
useEffect(() => { useEffect(() => {
if (!sharingKey) { if (!sharingKey) {
@@ -28,31 +34,34 @@ export default function Reader() {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`); const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
const { encrypted_content, encrypted_metadata, images } = response.data; 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 =
const decryptedMetadata = await crypto.decryptMetadataWithSharingKey( await cryptoUtils.decryptMetadataWithSharingKey(
encrypted_metadata, encrypted_metadata,
sharingKey, sharingKey,
); );
setMetadata(decryptedMetadata); setMetadata(decryptedMetadata);
// 2. Decrypt the main letter content const decryptedContent = await cryptoUtils.decryptLetterWithSharingKey(
const decryptedContent = await crypto.decryptLetterWithSharingKey(
encrypted_content, encrypted_content,
sharingKey, sharingKey,
); );
const json = JSON.parse(decryptedContent); const json = JSON.parse(decryptedContent);
// 3. Batch decrypt any images on the canvas
if (images && images.length > 0) { if (images && images.length > 0) {
await decryptCanvasImagesWithSharingKey(json, images, sharingKey); await decryptCanvasImagesWithSharingKey(
json,
images,
sharingKey,
cryptoUtils,
);
} }
setCanvasData(json); setDecryptedCanvasData(json);
setIsDecrypting(false);
} catch (err: any) { } catch (err: any) {
setError(`Failed to load letter: ${err.message || "Unknown error"}`); setError(`Failed to load letter: ${err.message || "Unknown error"}`);
} finally {
setIsDecrypting(false); setIsDecrypting(false);
} }
}; };
@@ -60,43 +69,66 @@ export default function Reader() {
loadAndDecrypt(); loadAndDecrypt();
}, [public_id, sharingKey]); }, [public_id, sharingKey]);
useEffect(() => {
if (!isDecrypting && decryptedCanvasData && canvasRef.current) {
canvasRef.current.loadData(decryptedCanvasData);
}
}, [isDecrypting, decryptedCanvasData]);
if (isDecrypting) { if (isDecrypting) {
return ( return (
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8"> <div className="min-h-screen flex items-center justify-center bg-base-200">
<span className="loading loading-ring loading-lg text-primary"></span> <div className="text-center space-y-4">
<p className="mt-4 text-sm opacity-50 font-medium">Decrypting...</p> <p className="text-base-content/60">Decrypting...</p>
</div>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8 text-center"> <div className="min-h-screen flex items-center justify-center bg-base-200 px-6">
<div className="alert alert-error max-w-md shadow-lg"> <div className="max-w-md w-full bg-base-100 shadow-xl rounded-2xl p-8 text-center space-y-4">
<CrossIcon size={24} /> <p className="text-error font-medium">{error}</p>
<span>{error}</span> <button
type="button"
className="btn btn-primary"
onClick={() => (window.location.href = "/")}
>
Back to Home
</button>
</div> </div>
<button
type="button"
className="btn btn-ghost mt-6"
onClick={() => (window.location.href = "/")}
>
Back to Home
</button>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen w-full bg-base-200 flex flex-col items-center justify-center p-8 gap-4 overflow-hidden"> <section className="min-h-screen w-full bg-base-200 px-4 py-8">
{metadata?.recipient && ( <div className="max-w-4xl mx-auto space-y-6">
<div className="mb-6 animate-in fade-in slide-in-from-top duration-1000"> <div className="flex items-center justify-between">
<h2 className="text-xl font-serif text-base-content/60 italic"> <div>
A sealed message for {metadata.recipient} {metadata?.recipient && (
</h2> <p className="text-base-content/60">
A sealed message for{" "}
<span className="font-semibold">
{metadata.recipient || "Anonymous"}
</span>
</p>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => (window.location.href = "/")}
>
<CrossIcon size={18} />
</button>
</div> </div>
)}
{canvasData && <ComposeCanvas initialData={canvasData} readOnly={true} />} <div className="bg-paper rounded-sm shadow-primary-content overflow-hidden">
</div> <ComposeCanvas ref={canvasRef} readOnly />
</div>
</div>
</section>
); );
} }
+197
View File
@@ -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",
);
});
});
});
+35 -39
View File
@@ -1,18 +1,18 @@
import { api } from "../api/apiClient"; import { api } from "../api/apiClient";
import { CryptoUtils } from "./crypto"; import type { CryptoUtils } from "./crypto";
import { blobUrlToFile } from "./fileUtils"; 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( export async function decryptCanvasImages(
canvasData: any, canvasData: any,
remoteImages: any[], remoteImages: any[],
encrypted_dek: string, encrypted_dek: string,
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils,
includeRawFile = false, includeRawFile = false,
) { ) {
if (!canvasData?.objects) return; if (!canvasData?.objects) return;
@@ -22,33 +22,32 @@ export async function decryptCanvasImages(
); );
for (const obj of canvasData.objects) { for (const obj of canvasData.objects) {
if (obj.type === "Image" && typeof obj.src === "string") { if (obj.type !== "Image") continue;
const remoteUrl = imageMap.get(obj.src);
if (!remoteUrl) continue;
try { const originalFilename = obj.src;
const res = await api.get(remoteUrl, { responseType: "blob" }); const remoteUrl = imageMap.get(originalFilename);
const blobUrl = await crypto.decryptImage( if (!remoteUrl) continue;
res.data,
encrypted_dek,
masterKey,
);
obj.src = blobUrl; const res = await api.get(remoteUrl, { responseType: "blob" });
if (includeRawFile) { const blobUrl = await cryptoUtils.decryptImage(
// We need the raw file in the editor so we can re-encrypt it if the user saves again. res.data,
obj._customRawFile = await blobUrlToFile(blobUrl, obj.src); encrypted_dek,
} masterKey,
} catch (_err) {} );
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( export async function decryptCanvasImagesWithSharingKey(
canvasData: any, canvasData: any,
remoteImages: any[], remoteImages: any[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils,
) { ) {
if (!canvasData?.objects) return; if (!canvasData?.objects) return;
@@ -57,36 +56,34 @@ export async function decryptCanvasImagesWithSharingKey(
); );
for (const obj of canvasData.objects) { for (const obj of canvasData.objects) {
if (obj.type === "Image" && typeof obj.src === "string") { if (obj.type !== "Image") continue;
const remoteUrl = imageMap.get(obj.src);
if (!remoteUrl) continue;
try { const remoteUrl = imageMap.get(obj.src);
const res = await api.get(remoteUrl, { responseType: "blob" }); if (!remoteUrl) continue;
obj.src = await crypto.decryptImageWithSharingKey(res.data, sharingKey);
} catch (_err) {} 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( export async function encryptCanvasImages(
canvasData: any, canvasData: any,
canvasImages: { src: string; file: File }[], canvasImages: CanvasImageRef[],
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils,
) { ) {
const encryptedFiles = new Map<string, Blob>(); const encryptedFiles = new Map<string, Blob>();
const filenameMapping = new Map<string, string>(); const filenameMapping = new Map<string, string>();
await crypto.initialize();
for (const img of canvasImages) { for (const img of canvasImages) {
// If it already ends in .bin, it was already encrypted.
if (img.src.endsWith(".bin")) continue; if (img.src.endsWith(".bin")) continue;
if (!img.file) continue;
try { try {
const { filename, encryptedBlob } = await crypto.encryptImage( const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
img.file, img.file,
masterKey, masterKey,
); );
@@ -95,7 +92,6 @@ export async function encryptCanvasImages(
} catch (_err) {} } catch (_err) {}
} }
// Update the canvas JSON to use the new encrypted filenames instead of blob URLs.
if (canvasData?.objects) { if (canvasData?.objects) {
canvasData.objects = canvasData.objects.map((obj: any) => { canvasData.objects = canvasData.objects.map((obj: any) => {
if (obj.type === "Image" && filenameMapping.has(obj.src)) { if (obj.type === "Image" && filenameMapping.has(obj.src)) {