- {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)) {