mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: move DrawerSection component and implement image encryption/decryption tests
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost mt-6"
|
className="btn btn-primary"
|
||||||
onClick={() => (window.location.href = "/")}
|
onClick={() => (window.location.href = "/")}
|
||||||
>
|
>
|
||||||
Back to Home
|
Back to Home
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
{metadata?.recipient && (
|
{metadata?.recipient && (
|
||||||
<div className="mb-6 animate-in fade-in slide-in-from-top duration-1000">
|
<p className="text-base-content/60">
|
||||||
<h2 className="text-xl font-serif text-base-content/60 italic">
|
A sealed message for{" "}
|
||||||
A sealed message for {metadata.recipient}
|
<span className="font-semibold">
|
||||||
</h2>
|
{metadata.recipient || "Anonymous"}
|
||||||
</div>
|
</span>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{canvasData && <ComposeCanvas initialData={canvasData} readOnly={true} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
>
|
||||||
|
<CrossIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-paper rounded-sm shadow-primary-content overflow-hidden">
|
||||||
|
<ComposeCanvas ref={canvasRef} readOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
|
const originalFilename = obj.src;
|
||||||
|
const remoteUrl = imageMap.get(originalFilename);
|
||||||
if (!remoteUrl) continue;
|
if (!remoteUrl) continue;
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
const res = await api.get(remoteUrl, { responseType: "blob" });
|
||||||
const blobUrl = await crypto.decryptImage(
|
const blobUrl = await cryptoUtils.decryptImage(
|
||||||
res.data,
|
res.data,
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
obj.src = blobUrl;
|
obj.src = blobUrl;
|
||||||
|
|
||||||
if (includeRawFile) {
|
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, originalFilename);
|
||||||
obj._customRawFile = await blobUrlToFile(blobUrl, obj.src);
|
|
||||||
}
|
|
||||||
} catch (_err) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
const remoteUrl = imageMap.get(obj.src);
|
||||||
if (!remoteUrl) continue;
|
if (!remoteUrl) continue;
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api.get(remoteUrl, { responseType: "blob" });
|
const res = await api.get(remoteUrl, { responseType: "blob" });
|
||||||
obj.src = await crypto.decryptImageWithSharingKey(res.data, sharingKey);
|
obj.src = await cryptoUtils.decryptImageWithSharingKey(
|
||||||
} catch (_err) {}
|
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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user