mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
feat: implement Reader page for viewing encrypted letters and add read-only mode to ComposeCanvas
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from rest_framework import generics
|
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 rest_framework.response import Response
|
||||||
|
|
||||||
from letters.models import Letter, LetterImage
|
from letters.models import Letter, LetterImage
|
||||||
@@ -18,11 +18,19 @@ class LetterView(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
class LetterDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
serializer_class = LetterSerializer
|
serializer_class = LetterSerializer
|
||||||
permission_classes = [IsAuthenticated]
|
|
||||||
lookup_field = "public_id"
|
lookup_field = "public_id"
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.request.method == "GET":
|
||||||
|
return [AllowAny()]
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
def get_queryset(self):
|
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):
|
def put(self, request, public_id):
|
||||||
# upsert: create if doesn't exist, else update
|
# upsert: create if doesn't exist, else update
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Editor from "./pages/Editor";
|
|||||||
// Pages
|
// Pages
|
||||||
import Home from "./pages/Home";
|
import Home from "./pages/Home";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
|
import Reader from "./pages/Reader";
|
||||||
import Register from "./pages/Register";
|
import Register from "./pages/Register";
|
||||||
import VerifyEmail from "./pages/VerifyEmail";
|
import VerifyEmail from "./pages/VerifyEmail";
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path={ROUTES.READ} element={<Reader />} />
|
||||||
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
<Route path="*" element={<Navigate to={ROUTES.HOME} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export interface FabricImageWithFile extends fabric.FabricImage {
|
|||||||
_customRawFile: File;
|
_customRawFile: File;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
export const ComposeCanvas = forwardRef<
|
||||||
|
CanvasTools,
|
||||||
|
{ readOnly?: boolean; initialData?: any }
|
||||||
|
>(({ readOnly = false, initialData = null }, ref) => {
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const fabricRef = useRef<fabric.Canvas | null>(null);
|
const fabricRef = useRef<fabric.Canvas | null>(null);
|
||||||
@@ -51,7 +54,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
canvas = new fabric.Canvas(canvasRef.current, {
|
canvas = new fabric.Canvas(canvasRef.current, {
|
||||||
width: finalWidth,
|
width: finalWidth,
|
||||||
height: initialHeight,
|
height: initialHeight,
|
||||||
selection: false,
|
selection: !readOnly,
|
||||||
preserveObjectStacking: true,
|
preserveObjectStacking: true,
|
||||||
allowTouchScrolling: true,
|
allowTouchScrolling: true,
|
||||||
});
|
});
|
||||||
@@ -61,65 +64,76 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
const wrapperEl = canvas.getElement().parentElement;
|
const wrapperEl = canvas.getElement().parentElement;
|
||||||
if (wrapperEl) wrapperEl.style.background = "transparent";
|
if (wrapperEl) wrapperEl.style.background = "transparent";
|
||||||
|
|
||||||
const textbox = new fabric.Textbox("Take a deep breath...", {
|
if (initialData) {
|
||||||
name: "main-textbox",
|
await canvas.loadFromJSON(initialData);
|
||||||
originX: "left",
|
if (readOnly) {
|
||||||
originY: "top",
|
canvas.getObjects().forEach((obj) => {
|
||||||
left: PAD,
|
obj.selectable = false;
|
||||||
top: PAD,
|
obj.evented = false;
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
});
|
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(() => {
|
textboxRef.current = textbox;
|
||||||
if (!isMounted) return;
|
canvas.add(textbox);
|
||||||
canvas?.setActiveObject(textbox);
|
|
||||||
textbox.enterEditing();
|
|
||||||
canvas?.renderAll();
|
|
||||||
|
|
||||||
const hiddenTextareas = document.querySelectorAll(
|
textbox.on("changed", () => {
|
||||||
'textarea[data-fabric="textarea"]',
|
if (!canvas || !wrapperRef.current) return;
|
||||||
);
|
const neededHeight = textbox.top + textbox.height + PAD;
|
||||||
hiddenTextareas.forEach((ta) => {
|
if (neededHeight > canvas.height) {
|
||||||
if (!ta.getAttribute("aria-label")) {
|
const newH = neededHeight + PAD;
|
||||||
ta.setAttribute("aria-label", "Canvas text input");
|
canvas.setDimensions({ height: newH });
|
||||||
|
wrapperRef.current.style.height = `${newH}px`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 100);
|
|
||||||
|
|
||||||
canvas.on("mouse:down", (opt) => {
|
setTimeout(() => {
|
||||||
if (!opt.target || opt.target === textbox) {
|
if (!isMounted) return;
|
||||||
canvas?.setActiveObject(textbox);
|
canvas?.setActiveObject(textbox);
|
||||||
textbox.enterEditing();
|
textbox.enterEditing();
|
||||||
canvas?.renderAll();
|
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();
|
init();
|
||||||
@@ -130,7 +144,7 @@ export const ComposeCanvas = forwardRef<CanvasTools>((_props, ref) => {
|
|||||||
fabricRef.current = null;
|
fabricRef.current = null;
|
||||||
textboxRef.current = null;
|
textboxRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [initialData, readOnly]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
addImage: (url: string, file: File) => {
|
addImage: (url: string, file: File) => {
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { act, renderHook } from "@testing-library/react";
|
import { act, renderHook } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import {
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
afterAll,
|
|
||||||
beforeAll,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from "vitest";
|
|
||||||
import { mockUser } from "../../test/fixtures/user.fixture";
|
import { mockUser } from "../../test/fixtures/user.fixture";
|
||||||
import { server } from "../../test/mocks/server";
|
import { server } from "../../test/mocks/server";
|
||||||
import { useAuthStore } from "../store/useAuthStore";
|
import { useAuthStore } from "../store/useAuthStore";
|
||||||
@@ -39,14 +31,6 @@ vi.mock("../utils/keystore", () => ({
|
|||||||
clearMasterKey: vi.fn().mockResolvedValue(undefined),
|
clearMasterKey: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("API_URL", API_URL);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
@@ -58,13 +42,13 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("isAuthenticated", () => {
|
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());
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
expect(result.current.isAuthenticated).toBe(false);
|
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({
|
useAuthStore.setState({
|
||||||
accessToken: "token",
|
accessToken: "token",
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
@@ -77,7 +61,7 @@ describe("isAuthenticated", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("login", () => {
|
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());
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -100,7 +84,7 @@ describe("login", () => {
|
|||||||
expect(saveMasterKey).toHaveBeenCalledTimes(1);
|
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());
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -147,7 +131,7 @@ describe("logout", () => {
|
|||||||
expect(logoutCalled).toBe(true);
|
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());
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -158,7 +142,7 @@ describe("logout", () => {
|
|||||||
expect(clearMasterKey).toHaveBeenCalledTimes(1);
|
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(
|
server.use(
|
||||||
http.post(
|
http.post(
|
||||||
`${API_URL}/api/auth/logout/`,
|
`${API_URL}/api/auth/logout/`,
|
||||||
@@ -200,7 +184,7 @@ describe("initialize", () => {
|
|||||||
expect(useAuthStore.getState().isInitializing).toBe(false);
|
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());
|
const { result } = renderHook(() => useAuth());
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -213,7 +197,7 @@ describe("initialize", () => {
|
|||||||
expect(useKeyStore.getState().masterKey).not.toBeNull();
|
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(
|
server.use(
|
||||||
http.post(
|
http.post(
|
||||||
`${API_URL}/api/auth/refresh/`,
|
`${API_URL}/api/auth/refresh/`,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Drawer from "./Drawer";
|
|||||||
|
|
||||||
describe("Drawer Page", () => {
|
describe("Drawer Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup authenticated state
|
// Setup authenticated state for the test
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
accessToken: "fake-token",
|
accessToken: "fake-token",
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export default function Drawer() {
|
|||||||
timestamp={letter.updated_at}
|
timestamp={letter.updated_at}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{sent.length === 0 && (
|
||||||
|
<p className="text-center text-base-content/20 mt-4">
|
||||||
|
This drawer remains silent
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</DrawerSection>
|
</DrawerSection>
|
||||||
<DrawerSection
|
<DrawerSection
|
||||||
id="vault"
|
id="vault"
|
||||||
|
|||||||
+119
-119
@@ -17,17 +17,7 @@ import { endpoints } from "../config/endpoints";
|
|||||||
import { PATHS } from "../config/routes";
|
import { PATHS } from "../config/routes";
|
||||||
import { useKeyStore } from "../store/useKeyStore";
|
import { useKeyStore } from "../store/useKeyStore";
|
||||||
import { CryptoUtils } from "../utils/crypto";
|
import { CryptoUtils } from "../utils/crypto";
|
||||||
|
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
|
||||||
// convert blob url to file
|
|
||||||
async function blobUrlToFile(
|
|
||||||
blobUrl: string,
|
|
||||||
fileName: string,
|
|
||||||
mimeType?: string,
|
|
||||||
): Promise<File> {
|
|
||||||
const response = await fetch(blobUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
return new File([blob], fileName, { type: mimeType ?? blob.type });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -37,6 +27,7 @@ export default function Editor() {
|
|||||||
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
const [isInitialLoading, setIsInitialLoading] = useState(false);
|
||||||
const [isSealing, setIsSealing] = useState(false);
|
const [isSealing, setIsSealing] = useState(false);
|
||||||
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
const [isSaveSuccess, setIsSaveSuccess] = useState(false);
|
||||||
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
|
|
||||||
const [recipient, setRecipient] = useState("");
|
const [recipient, setRecipient] = useState("");
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
@@ -50,13 +41,13 @@ export default function Editor() {
|
|||||||
|
|
||||||
const loadExistingLetter = async () => {
|
const loadExistingLetter = async () => {
|
||||||
setIsInitialLoading(true);
|
setIsInitialLoading(true);
|
||||||
const cryptoUtils = new CryptoUtils();
|
const crypto = 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;
|
||||||
|
|
||||||
// metadata for recipient
|
// 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,
|
||||||
@@ -65,8 +56,8 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
setRecipient(metadata.recipient || "");
|
setRecipient(metadata.recipient || "");
|
||||||
|
|
||||||
// decrypt canvas data
|
// 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,
|
||||||
@@ -75,45 +66,16 @@ export default function Editor() {
|
|||||||
);
|
);
|
||||||
const canvasData = JSON.parse(decryptedJsonStr);
|
const canvasData = JSON.parse(decryptedJsonStr);
|
||||||
|
|
||||||
// traverse through canvas images and replace encrypted image with decrypted image
|
// Batch decrypt images within the canvas
|
||||||
if (canvasData.objects) {
|
await decryptCanvasImages(
|
||||||
for (const obj of canvasData.objects) {
|
canvasData,
|
||||||
if (obj.type === "Image" && typeof obj.src === "string") {
|
letterData.images,
|
||||||
const filename = obj.src;
|
letterData.encrypted_dek,
|
||||||
const remoteImage = letterData.images.find(
|
masterKey,
|
||||||
(img: any) => img.file_name === filename,
|
true, // restore raw files for the editor
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remoteImage) {
|
// Load data into the Fabric canvas
|
||||||
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
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
canvasRef.current?.loadData(canvasData);
|
canvasRef.current?.loadData(canvasData);
|
||||||
});
|
});
|
||||||
@@ -129,7 +91,7 @@ export default function Editor() {
|
|||||||
|
|
||||||
// --------------------------------------------------------------------------------------
|
// --------------------------------------------------------------------------------------
|
||||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]; // pick one file at a time
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
const url = URL.createObjectURL(file);
|
const url = URL.createObjectURL(file);
|
||||||
canvasRef.current?.addImage(url, file);
|
canvasRef.current?.addImage(url, file);
|
||||||
@@ -138,88 +100,80 @@ export default function Editor() {
|
|||||||
|
|
||||||
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
const handleSave = async (status: "SEALED" | "DRAFT"): Promise<void> => {
|
||||||
if (!public_id && !letterIdRef.current) {
|
if (!public_id && !letterIdRef.current) {
|
||||||
// if no uuid slug, then generate a new one and update params
|
|
||||||
letterIdRef.current = crypto.randomUUID();
|
letterIdRef.current = crypto.randomUUID();
|
||||||
navigate(PATHS.write(letterIdRef.current), { replace: true });
|
navigate(PATHS.write(letterIdRef.current), { replace: true });
|
||||||
} else if (public_id) {
|
} else if (public_id) {
|
||||||
letterIdRef.current = public_id;
|
letterIdRef.current = public_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSealing) return;
|
if (isSealing || !masterKey) return;
|
||||||
setIsSealing(true);
|
setIsSealing(true);
|
||||||
|
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
await cryptoUtils.initialize();
|
await cryptoUtils.initialize();
|
||||||
|
|
||||||
const images = canvasRef.current?.getImages() || [];
|
|
||||||
const imageEncMap = new Map<string, string>();
|
|
||||||
const encImageFilesMap = new Map<string, Blob>();
|
|
||||||
|
|
||||||
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 {
|
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);
|
await api.put(`${endpoints.LETTERS}${letterIdRef.current}/`, formData);
|
||||||
setIsSaveSuccess(true);
|
setIsSaveSuccess(true);
|
||||||
setTimeout(() => {
|
|
||||||
setIsSaveSuccess(false);
|
if (status === "SEALED" && encrypted_letter.sharingKey) {
|
||||||
}, 5000);
|
const link = `${window.location.origin}${PATHS.read(letterIdRef.current)}#${encrypted_letter.sharingKey}`;
|
||||||
|
setShareLink(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setIsSaveSuccess(false), 5000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error sealing letter:", error);
|
console.error("Save failed:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSealing(false);
|
setIsSealing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!shareLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareLink);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300 relative">
|
<section className="flex-1 overflow-y-auto scrollbar-hide px-2 py-12 bg-base-300 relative">
|
||||||
{isInitialLoading && (
|
{isInitialLoading && (
|
||||||
@@ -236,7 +190,53 @@ export default function Editor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSaveSuccess && (
|
{/* Sharing Modal */}
|
||||||
|
{shareLink && (
|
||||||
|
<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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onClick={() => setShareLink(null)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-center text-center gap-6 py-4">
|
||||||
|
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
|
||||||
|
<LockIcon size={32} weight="fill" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-serif text-3xl">Sealed & Ready</h3>
|
||||||
|
<p className="text-base-content/60 text-sm max-w-xs">
|
||||||
|
This letter is now encrypted. Share this secret link with your
|
||||||
|
recipient.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full flex items-center gap-2 bg-base-300 p-2 rounded-xl group relative">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={shareLink}
|
||||||
|
className="flex-1 bg-transparent text-xs font-mono px-2 overflow-hidden text-ellipsis whitespace-nowrap outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="btn btn-primary btn-sm rounded-lg"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-base-content/30">
|
||||||
|
Zero-Knowledge: The key is in the link, not our servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSaveSuccess && !shareLink && (
|
||||||
<div
|
<div
|
||||||
className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out
|
className="modal modal-open bg-base-100 backdrop-blur-md transition-all duration-2000 ease-in-out
|
||||||
animate-fade-in opacity-80"
|
animate-fade-in opacity-80"
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { HttpResponse, http } from "msw";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { server } from "../../test/mocks/server";
|
||||||
|
import { endpoints } from "../config/endpoints";
|
||||||
|
import Login from "./Login";
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
describe("Login Page", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the sign-in form correctly", () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MemoryRouter initialEntries={["/login"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/drawer" element={<div>Drawer</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
<MemoryRouter initialEntries={["/read/123"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/read/:public_id" element={<Reader />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/read/:public_id" element={<Reader />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<MemoryRouter initialEntries={[`/read/${mockPublicId}#${mockKey}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/read/:public_id" element={<Reader />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Failed to load letter/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [canvasData, setCanvasData] = useState<any>(null);
|
||||||
|
const [metadata, setMetadata] = useState<any>(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 (
|
||||||
|
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8">
|
||||||
|
<span className="loading loading-ring loading-lg text-primary"></span>
|
||||||
|
<p className="mt-4 text-sm opacity-50 font-medium">Decrypting...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-200 flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="alert alert-error max-w-md shadow-lg">
|
||||||
|
<CrossIcon size={24} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost mt-6"
|
||||||
|
onClick={() => (window.location.href = "/")}
|
||||||
|
>
|
||||||
|
Back to Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-base-200 flex flex-col items-center justify-center p-8 gap-4 overflow-hidden">
|
||||||
|
{metadata?.recipient && (
|
||||||
|
<div className="mb-6 animate-in fade-in slide-in-from-top duration-1000">
|
||||||
|
<h2 className="text-xl font-serif text-base-content/60 italic">
|
||||||
|
A sealed message for {metadata.recipient}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canvasData && <ComposeCanvas initialData={canvasData} readOnly={true} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -116,4 +116,41 @@ describe("encryptImage / decryptImage", () => {
|
|||||||
expect(result.filename).not.toMatch(/photo|jpg/);
|
expect(result.filename).not.toMatch(/photo|jpg/);
|
||||||
expect(encryptedText).not.toContain("image-data");
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ export interface EncryptedImageUpload {
|
|||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Wrapper functions
|
|
||||||
*/
|
|
||||||
interface SealedEnvelope {
|
interface SealedEnvelope {
|
||||||
encryptedContent: string;
|
encryptedContent: string;
|
||||||
encrypted_dek: string;
|
encrypted_dek: string;
|
||||||
@@ -33,6 +30,7 @@ export class CryptoUtils {
|
|||||||
private static readonly PBKDF2_ITERATIONS = 100_000;
|
private static readonly PBKDF2_ITERATIONS = 100_000;
|
||||||
private static readonly AES_GCM = { name: "AES-GCM", length: 256 };
|
private static readonly AES_GCM = { name: "AES-GCM", length: 256 };
|
||||||
|
|
||||||
|
// Generates a fresh Data Encryption Key (DEK)
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [
|
this.dek = await crypto.subtle.generateKey(CryptoUtils.AES_GCM, true, [
|
||||||
"encrypt",
|
"encrypt",
|
||||||
@@ -44,7 +42,6 @@ export class CryptoUtils {
|
|||||||
toBase64 = (buf: Uint8Array): string =>
|
toBase64 = (buf: Uint8Array): string =>
|
||||||
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
|
btoa(buf.reduce((s, b) => s + String.fromCharCode(b), ""));
|
||||||
|
|
||||||
// explicit loop ensures Uint8Array<ArrayBuffer> (not ArrayBufferLike)
|
|
||||||
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
|
fromBase64 = (b64: string): Uint8Array<ArrayBuffer> => {
|
||||||
const str = atob(b64);
|
const str = atob(b64);
|
||||||
const arr = new Uint8Array(str.length);
|
const arr = new Uint8Array(str.length);
|
||||||
@@ -60,24 +57,22 @@ export class CryptoUtils {
|
|||||||
return this.toBase64(packed);
|
return this.toBase64(packed);
|
||||||
};
|
};
|
||||||
|
|
||||||
// split IV (first 12 bytes) back out from a packed base64 bundle
|
|
||||||
unpackWithIv = (
|
unpackWithIv = (
|
||||||
b64: string,
|
b64: string,
|
||||||
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
|
): [Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>] => {
|
||||||
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)];
|
return [new Uint8Array(buf.buffer, 0, 12), new Uint8Array(buf.buffer, 12)];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derives a Master Key from a password and email (salt).
|
* Derives a Master Key from a password + email (salt).
|
||||||
* Deterministic — same credentials always produce the same key.
|
* Same credentials = same key.
|
||||||
*/
|
*/
|
||||||
public static async deriveMasterKey(
|
public static async deriveMasterKey(
|
||||||
password: string,
|
password: string,
|
||||||
email: string,
|
email: string,
|
||||||
): Promise<CryptoKey> {
|
): Promise<CryptoKey> {
|
||||||
const enc = new TextEncoder();
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
const baseKey = await crypto.subtle.importKey(
|
const baseKey = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
enc.encode(password),
|
enc.encode(password),
|
||||||
@@ -100,11 +95,11 @@ export class CryptoUtils {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal helper to encrypt data and wrap the key
|
||||||
private async sealEnvelope(
|
private async sealEnvelope(
|
||||||
input: Uint8Array,
|
input: Uint8Array,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<SealedEnvelope> {
|
): Promise<SealedEnvelope> {
|
||||||
// copy into a fresh ArrayBuffer — WebCrypto requires ArrayBuffer-backed arrays
|
|
||||||
const plainBytes = new Uint8Array(input);
|
const plainBytes = new Uint8Array(input);
|
||||||
|
|
||||||
// encrypt the content with the DEK
|
// 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(
|
private async openEnvelope(
|
||||||
encryptedContent: string,
|
encryptedContent: string,
|
||||||
encrypted_dek: string,
|
encrypted_dek: string,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<Uint8Array<ArrayBuffer>> {
|
): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
// unwrap the DEK using the master key
|
|
||||||
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
const [dekIv, wrappedDek] = this.unpackWithIv(encrypted_dek);
|
||||||
const dek = await crypto.subtle.unwrapKey(
|
const dek = await crypto.subtle.unwrapKey(
|
||||||
"raw",
|
"raw",
|
||||||
@@ -149,7 +144,29 @@ export class CryptoUtils {
|
|||||||
["decrypt"],
|
["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<Uint8Array<ArrayBuffer>> {
|
||||||
|
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 [contentIv, ciphertext] = this.unpackWithIv(encryptedContent);
|
||||||
const plainBytes = await crypto.subtle.decrypt(
|
const plainBytes = await crypto.subtle.decrypt(
|
||||||
{ name: "AES-GCM", iv: contentIv },
|
{ name: "AES-GCM", iv: contentIv },
|
||||||
@@ -169,7 +186,6 @@ export class CryptoUtils {
|
|||||||
): Promise<EncryptedLetter> {
|
): Promise<EncryptedLetter> {
|
||||||
const { encryptedContent, encrypted_dek, sharingKey } =
|
const { encryptedContent, encrypted_dek, sharingKey } =
|
||||||
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
await this.sealEnvelope(new TextEncoder().encode(plaintext), masterKey);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,17 +193,25 @@ export class CryptoUtils {
|
|||||||
{ encrypted_content, encrypted_dek }: EncryptedLetter,
|
{ encrypted_content, encrypted_dek }: EncryptedLetter,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const plainBytes = await this.openEnvelope(
|
const bytes = await this.openEnvelope(
|
||||||
encrypted_content,
|
encrypted_content,
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
return new TextDecoder().decode(plainBytes);
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async decryptLetterWithSharingKey(
|
||||||
|
encrypted_content: string,
|
||||||
|
sharingKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const bytes = await this.openEnvelopeWithSharingKey(
|
||||||
|
encrypted_content,
|
||||||
|
sharingKey,
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Metadata functions
|
|
||||||
*/
|
|
||||||
public async encryptMetadata(
|
public async encryptMetadata(
|
||||||
metadata: Record<string, unknown>,
|
metadata: Record<string, unknown>,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
@@ -197,7 +221,6 @@ export class CryptoUtils {
|
|||||||
new TextEncoder().encode(JSON.stringify(metadata)),
|
new TextEncoder().encode(JSON.stringify(metadata)),
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
return { encrypted_content: encryptedContent, encrypted_dek, sharingKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,17 +228,25 @@ export class CryptoUtils {
|
|||||||
encrypted_metadata: EncryptedLetter,
|
encrypted_metadata: EncryptedLetter,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<Record<string, any>> {
|
): Promise<Record<string, any>> {
|
||||||
const plainBytes = await this.openEnvelope(
|
const bytes = await this.openEnvelope(
|
||||||
encrypted_metadata.encrypted_content,
|
encrypted_metadata.encrypted_content,
|
||||||
encrypted_metadata.encrypted_dek,
|
encrypted_metadata.encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plainBytes));
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async decryptMetadataWithSharingKey(
|
||||||
|
encrypted_content: string,
|
||||||
|
sharingKey: string,
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
|
const bytes = await this.openEnvelopeWithSharingKey(
|
||||||
|
encrypted_content,
|
||||||
|
sharingKey,
|
||||||
|
);
|
||||||
|
return JSON.parse(new TextDecoder().decode(bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Image functions
|
|
||||||
*/
|
|
||||||
public async encryptImage(
|
public async encryptImage(
|
||||||
file: File,
|
file: File,
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
@@ -239,13 +270,23 @@ export class CryptoUtils {
|
|||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
const plainBytes = await this.openEnvelope(
|
const bytes = await this.openEnvelope(
|
||||||
this.toBase64(encryptedBytes),
|
this.toBase64(encryptedBytes),
|
||||||
encrypted_dek,
|
encrypted_dek,
|
||||||
masterKey,
|
masterKey,
|
||||||
);
|
);
|
||||||
|
return URL.createObjectURL(new Blob([bytes]));
|
||||||
|
}
|
||||||
|
|
||||||
// return as object URL for use in Fabric / <img>
|
public async decryptImageWithSharingKey(
|
||||||
return URL.createObjectURL(new Blob([plainBytes]));
|
encryptedBlob: Blob,
|
||||||
|
sharingKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer());
|
||||||
|
const bytes = await this.openEnvelopeWithSharingKey(
|
||||||
|
this.toBase64(encryptedBytes),
|
||||||
|
sharingKey,
|
||||||
|
);
|
||||||
|
return URL.createObjectURL(new Blob([bytes]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<File> {
|
||||||
|
const response = await fetch(blobUrl);
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new File([blob], fileName, { type: mimeType ?? blob.type });
|
||||||
|
}
|
||||||
@@ -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<string, Blob>();
|
||||||
|
const filenameMapping = new Map<string, string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user