diff --git a/backend/letters/urls.py b/backend/letters/urls.py index 4edbc39..56476a4 100644 --- a/backend/letters/urls.py +++ b/backend/letters/urls.py @@ -1,8 +1,8 @@ from django.urls import path -from .views import LetterView +from .views import LetterDetailView, LetterView urlpatterns = [ path("", LetterView.as_view(), name="letter-list-create"), - path("/", LetterView.as_view(), name="letter-create-retrieve-update-delete"), + path("/", LetterDetailView.as_view(), name="letter-detail"), ] diff --git a/backend/letters/views.py b/backend/letters/views.py index 71c6dcc..4d6be6d 100644 --- a/backend/letters/views.py +++ b/backend/letters/views.py @@ -15,23 +15,30 @@ class LetterView(generics.ListCreateAPIView): """return only letters of the authenticated user""" return Letter.objects.filter(user=self.request.user) + +class LetterDetailView(generics.RetrieveUpdateDestroyAPIView): + serializer_class = LetterSerializer + permission_classes = [IsAuthenticated] + lookup_field = "public_id" + + def get_queryset(self): + return Letter.objects.filter(user=self.request.user) + def put(self, request, public_id): - # avoiding deepcopy due to osmething called pickle - data = request.data.dict() - print(data) - # remove public_id from data to avoid UniqueValidator firing - # since we use it from the URL for update_or_create anyway - data.pop("public_id", None) - serializer = self.get_serializer(data=data) + # upsert: create if doesn't exist, else update + letter, created = Letter.objects.get_or_create(public_id=public_id, user=request.user) + # request.data handles both JSON and Multipart automatically in DRF + serializer = self.get_serializer(letter, data=request.data, partial=True) serializer.is_valid(raise_exception=True) + serializer.save() - letter, created = Letter.objects.update_or_create( - public_id=public_id, user=self.request.user, defaults=serializer.validated_data - ) - - LetterImage.objects.filter(letter=letter).delete() - for image_file in request.FILES.getlist("image_files"): - LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name) + # Note: image_files is a list of binary files in request.FILES + if "image_files" in request.FILES: + letter.images.all().delete() + for image_file in request.FILES.getlist("image_files"): + LetterImage.objects.create(letter=letter, file=image_file, file_name=image_file.name) + # Return fresh data including the new image URLs + serializer = self.get_serializer(letter) return Response(serializer.data, status=201 if created else 200) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74cab77..743df02 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -72,7 +72,7 @@ export default function App() { } /> diff --git a/frontend/src/components/ui/ComposeCanvas.tsx b/frontend/src/components/ui/ComposeCanvas.tsx index 386d363..c8ada3f 100644 --- a/frontend/src/components/ui/ComposeCanvas.tsx +++ b/frontend/src/components/ui/ComposeCanvas.tsx @@ -10,6 +10,7 @@ export type CanvasTools = { getData: () => { objects: CanvasJSON["objects"] }; // no-any hack :/ getJsonData: () => string; getImages: () => { src: string; file: File }[]; + loadData: (data: any) => Promise; }; export interface FabricImageWithFile extends fabric.FabricImage { @@ -27,7 +28,6 @@ export const ComposeCanvas = forwardRef((_props, ref) => { let canvas: fabric.Canvas | null = null; const init = async () => { - // lazy populate await document.fonts.ready; const waitForLayout = (): Promise => { return new Promise((resolve) => { @@ -48,23 +48,21 @@ export const ComposeCanvas = forwardRef((_props, ref) => { 600, ); - // init canvas canvas = new fabric.Canvas(canvasRef.current, { width: finalWidth, height: initialHeight, selection: false, preserveObjectStacking: true, - allowTouchScrolling: true, // for mobile + allowTouchScrolling: true, }); fabricRef.current = canvas; - // transparent background const wrapperEl = canvas.getElement().parentElement; if (wrapperEl) wrapperEl.style.background = "transparent"; - // the core textbox const textbox = new fabric.Textbox("Take a deep breath...", { + name: "main-textbox", originX: "left", originY: "top", left: PAD, @@ -78,7 +76,7 @@ export const ComposeCanvas = forwardRef((_props, ref) => { editable: true, hasControls: false, hasBorders: false, - objectCaching: false, // for font crispness + objectCaching: false, splitByGrapheme: false, lockMovementX: true, lockMovementY: true, @@ -89,7 +87,6 @@ export const ComposeCanvas = forwardRef((_props, ref) => { textboxRef.current = textbox; canvas.add(textbox); - // automatically adjust height textbox.on("changed", () => { if (!canvas || !wrapperRef.current) return; const neededHeight = textbox.top + textbox.height + PAD; @@ -100,15 +97,12 @@ export const ComposeCanvas = forwardRef((_props, ref) => { } }); - // auto focus setTimeout(() => { if (!isMounted) return; canvas?.setActiveObject(textbox); textbox.enterEditing(); canvas?.renderAll(); - // Accessibility fix for Fabric.js hidden textarea - // searching globally in case it is appended to body const hiddenTextareas = document.querySelectorAll( 'textarea[data-fabric="textarea"]', ); @@ -151,8 +145,7 @@ export const ComposeCanvas = forwardRef((_props, ref) => { fabricRef.current?.add(img); fabricRef.current?.setActiveObject(img); fabricRef.current?.requestRenderAll(); - - URL.revokeObjectURL(url); // cleanup browser upload + URL.revokeObjectURL(url); }); }, getData: () => { @@ -167,12 +160,36 @@ export const ComposeCanvas = forwardRef((_props, ref) => { if (!fabricRef.current) return []; const images = fabricRef.current.getObjects( "Image", - ) as FabricImageWithFile[]; + ) as fabric.FabricImage[]; return images.map((img) => ({ - src: (img.getElement() as HTMLImageElement).currentSrc, - file: img._customRawFile, + src: img.getSrc(), + file: (img as any)._customRawFile, })); }, + loadData: async (data: any) => { + if (!fabricRef.current) return; + await fabricRef.current.loadFromJSON(data); + + // find the textbox and restore focus + const objects = fabricRef.current.getObjects("Textbox"); + if (objects.length > 0) { + const textbox = objects[0] as fabric.Textbox; + textbox.lockMovementX = true; + textbox.lockMovementY = true; + textbox.hasControls = false; + textbox.hasBorders = false; + textboxRef.current = textbox; + fabricRef.current.setActiveObject(textbox); + if (textbox.text) { + // move cursor to end + textbox.selectionStart = textbox.text.length; + textbox.selectionEnd = textbox.text.length; + } + textbox.enterEditing(); + } + + fabricRef.current.renderAll(); + }, })); return ( diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..daabf18 --- /dev/null +++ b/frontend/src/components/ui/Drawer.tsx @@ -0,0 +1,64 @@ +interface DrawerSectionProps { + id: string; + title: string; + count: string; + isOpen: boolean; + onClick: () => void; + children: React.ReactNode; +} + +export function DrawerSection({ + id, + title, + count, + isOpen, + onClick, + children, +}: DrawerSectionProps) { + return ( +
+
+ {children} +
+ + +
+ ); +} diff --git a/frontend/src/components/ui/LetterItem.tsx b/frontend/src/components/ui/LetterItem.tsx new file mode 100644 index 0000000..de11df0 --- /dev/null +++ b/frontend/src/components/ui/LetterItem.tsx @@ -0,0 +1,38 @@ +import { useNavigate } from "react-router-dom"; +import { ROUTES } from "../../config/routes"; + +export function LetterItem({ + preview, + timestamp, + id, + status, +}: { + preview: string; + timestamp: string; + id: string; + status: "DRAFT" | "SEALED" | "BURNED"; +}) { + const navigate = useNavigate(); + function handleNavigate(): void { + if (status === "SEALED") { + navigate(ROUTES.READ(id)); + } else { + navigate(ROUTES.WRITE(id)); + } + } + + return ( + + ); +} diff --git a/frontend/src/config/routes.ts b/frontend/src/config/routes.ts index f698b48..c0d52ac 100644 --- a/frontend/src/config/routes.ts +++ b/frontend/src/config/routes.ts @@ -5,7 +5,6 @@ export const ROUTES = { ACTIVATE: "/activate/:uidb64/:token", LOGIN: "/login", DRAWER: "/drawer", - WRITE: (public_id?: string) => - `/quill/${public_id ? public_id : ":public_id?"}`, - READ: "/read", + WRITE: (public_id?: string) => `/quill/${public_id ? public_id : ""}`, + READ: (public_id?: string) => `/read/${public_id ? public_id : ""}`, }; diff --git a/frontend/src/hooks/useLetters.tsx b/frontend/src/hooks/useLetters.tsx new file mode 100644 index 0000000..55a4cb4 --- /dev/null +++ b/frontend/src/hooks/useLetters.tsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from "react"; +import { api } from "../api/apiClient"; +import { endpoints } from "../config/endpoints"; +import { useKeyStore } from "../store/useKeyStore"; +import { CryptoUtils } from "../utils/crypto"; + +export interface Letter { + public_id: string; + type: "KEPT" | "VAULT" | "SENT"; + status: "DRAFT" | "SEALED" | "BURNED"; + updated_at: string; + sealed_at?: string; + unlock_at?: string; + encrypted_metadata: string; + encrypted_content: string; + encrypted_dek: string; +} + +export interface LetterMetadata { + recipient: string; + tags?: string[]; +} + +export interface ProcessedLetter extends Letter { + metadata: LetterMetadata; +} + +async function decryptLetters( + letters: Letter[], + masterKey: CryptoKey, +): Promise { + const cryptoUtils = new CryptoUtils(); + + return Promise.all( + letters.map(async (letter) => { + try { + const metadata = (await cryptoUtils.decryptMetadata( + { + encrypted_content: letter.encrypted_metadata, + encrypted_dek: letter.encrypted_dek, + }, + masterKey, + )) as LetterMetadata; + + return { ...letter, metadata }; + } catch (err) { + console.warn("Decryption failed for letter:", letter.public_id, err); + return { + ...letter, + metadata: { recipient: "Encrypted Letter" }, + }; + } + }), + ); +} + +export function useLetters() { + const [letters, setLetters] = useState([]); + const [loading, setLoading] = useState(false); + const { masterKey } = useKeyStore(); + + useEffect(() => { + if (!masterKey) return; + + setLoading(true); + api + .get(endpoints.LETTERS) + .then((res) => decryptLetters(res.data, masterKey)) + .then(setLetters) + .catch((err) => console.error("Drawer load failed:", err)) + .finally(() => setLoading(false)); + }, [masterKey]); + + const drawerItems = useMemo(() => { + return { + drafts: letters.filter((l) => l.status === "DRAFT"), + kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"), + vault: letters.filter((l) => l.type === "VAULT"), + sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"), + }; + }, [letters]); + + return { + ...drawerItems, + loading, + refreshLetters: () => setLoading(true), + }; +} diff --git a/frontend/src/pages/Drawer.test.tsx b/frontend/src/pages/Drawer.test.tsx new file mode 100644 index 0000000..9620817 --- /dev/null +++ b/frontend/src/pages/Drawer.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it } from "vitest"; +import { mockUser } from "../../test/fixtures/user.fixture"; +import { useAuthStore } from "../store/useAuthStore"; +import Drawer from "./Drawer"; + +describe("Drawer Page", () => { + beforeEach(() => { + // Setup authenticated state + useAuthStore.setState({ + user: mockUser, + accessToken: "fake-token", + isInitializing: false, + }); + }); + + it("renders the cabinet sections and empty state message", () => { + render( + + + , + ); + + expect(screen.getByText(/Drafts/i)).toBeInTheDocument(); + expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Vault/i)).toBeInTheDocument(); + expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Drawer.tsx b/frontend/src/pages/Drawer.tsx index 16912a6..cb6949f 100644 --- a/frontend/src/pages/Drawer.tsx +++ b/frontend/src/pages/Drawer.tsx @@ -1,28 +1,153 @@ +import { FeatherIcon } from "@phosphor-icons/react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import Logo from "../components/Logo"; +import { DrawerSection } from "../components/ui/Drawer"; +import { LetterItem } from "../components/ui/LetterItem"; +import { ROUTES } from "../config/routes"; import { useAuth } from "../hooks/useAuth"; +import { useLetters } from "../hooks/useLetters"; export default function Drawer() { const { user, logout } = useAuth(); + const [openSection, setOpenSection] = useState("kept"); + const navigate = useNavigate(); + const { drafts, kept, sent, vault, loading } = useLetters(); if (!user) return null; - return ( -
-
-

- Your Drawer -

-

Welcome back, {user.full_name}

-
+ const toggleSection = (id: string) => + setOpenSection(openSection === id ? null : id); -
+ return ( +
+
+ +
+ +
+ Personal Archive +
+
+ Welcome Back{" "} + {user.full_name} + +
+
+ +
+ {loading ? ( +
+ + + Opening your cabinet... + +
+ ) : ( + <> + toggleSection("drafts")} + > + {drafts.map((draft) => ( + + ))} + + + toggleSection("kept")} + > + {kept.map((letter) => ( + + ))} + + toggleSection("sent")} + > + {sent.map((letter) => ( + + ))} + + toggleSection("vault")} + > + {vault.map((letter) => ( + + ))} + + + )} +
+ +
+ Kept. Unsent. +
); } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index a1b2eb6..d45a85e 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -2,9 +2,10 @@ import { DownloadSimpleIcon, ImageIcon, LockIcon, + SpinnerGapIcon, TrayIcon, } from "@phosphor-icons/react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { api } from "../api/apiClient"; import { @@ -17,20 +18,116 @@ import { ROUTES } from "../config/routes"; import { useKeyStore } from "../store/useKeyStore"; import { CryptoUtils } from "../utils/crypto"; +// convert blob url to file +async function blobUrlToFile( + blobUrl: string, + fileName: string, + mimeType?: string, +): Promise { + const response = await fetch(blobUrl); + const blob = await response.blob(); + return new File([blob], fileName, { type: mimeType ?? blob.type }); +} + export default function Editor() { const navigate = useNavigate(); - // check for existing letter const { public_id } = useParams(); const letterIdRef = useRef(public_id ?? ""); + const [isInitialLoading, setIsInitialLoading] = useState(false); const [isSealing, setIsSealing] = useState(false); const [isSaveSuccess, setIsSaveSuccess] = useState(false); const [recipient, setRecipient] = useState(""); - const masterKey = useKeyStore.getState().masterKey; + const { masterKey } = useKeyStore(); const canvasRef = useRef(null); const fileInputRef = useRef(null); + + // Initial load: Fetch and decrypt existing letter + useEffect(() => { + if (!public_id || !masterKey) return; + + const loadExistingLetter = async () => { + setIsInitialLoading(true); + const cryptoUtils = new CryptoUtils(); + try { + const res = await api.get(`${endpoints.LETTERS}${public_id}/`); + const letterData = res.data; + + // metadata for recipient + const metadata = await cryptoUtils.decryptMetadata( + { + encrypted_content: letterData.encrypted_metadata, + encrypted_dek: letterData.encrypted_dek, + }, + masterKey, + ); + setRecipient(metadata.recipient || ""); + + // decrypt canvas data + const decryptedJsonStr = await cryptoUtils.decryptLetter( + { + encrypted_content: letterData.encrypted_content, + encrypted_dek: letterData.encrypted_dek, + }, + masterKey, + ); + const canvasData = JSON.parse(decryptedJsonStr); + + // traverse through canvas images and replace encrypted image with decrypted image + if (canvasData.objects) { + for (const obj of canvasData.objects) { + if (obj.type === "Image" && typeof obj.src === "string") { + const filename = obj.src; + const remoteImage = letterData.images.find( + (img: any) => img.file_name === filename, + ); + + if (remoteImage) { + try { + // fetch encrypted image blob using authenticated API + const imageRes = await api.get(remoteImage.file, { + responseType: "blob", + }); + const encryptedBlob = imageRes.data; + + // decrypt image blob + const blobUrl = await cryptoUtils.decryptImage( + encryptedBlob, + letterData.encrypted_dek, + masterKey, + ); + obj.src = blobUrl; + obj._customRawFile = await blobUrlToFile(blobUrl, filename); + console.log("Decrypted image object:", obj); + } catch (imgErr) { + console.error( + "Failed to decrypt image object:", + filename, + imgErr, + ); + } + } + } + } + } + + // load updated data into canvas + requestAnimationFrame(() => { + canvasRef.current?.loadData(canvasData); + }); + } catch (err) { + console.error("Failed to load existing letter:", err); + } finally { + setIsInitialLoading(false); + } + }; + + loadExistingLetter(); + }, [public_id, masterKey]); + + // -------------------------------------------------------------------------------------- const handleImageUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; // pick one file at a time if (file) { @@ -39,11 +136,13 @@ export default function Editor() { } }; - async function handleSeal(): Promise { - if (!public_id) { + const handleSave = async (status: "SEALED" | "DRAFT"): Promise => { + if (!public_id && !letterIdRef.current) { // if no uuid slug, then generate a new one and update params letterIdRef.current = crypto.randomUUID(); navigate(ROUTES.WRITE(letterIdRef.current), { replace: true }); + } else if (public_id) { + letterIdRef.current = public_id; } if (isSealing) return; @@ -60,27 +159,32 @@ export default function Editor() { } for (const image of images) { - const encrypted_image = await cryptoUtils.encryptImage( - image.file, - masterKey, - ); - imageEncMap.set(image.src, encrypted_image.filename); - encImageFilesMap.set( - encrypted_image.filename, - encrypted_image.encryptedBlob, - ); + 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(); - canvasData?.objects?.map( - ( - obj: Record, // fabric is too quirky for any other type - ) => - obj.type === "Image" - ? { ...obj, src: imageEncMap.get(obj.src as string) } - : obj, - ); + 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), @@ -88,25 +192,14 @@ export default function Editor() { ); const encrypted_metadata = await cryptoUtils.encryptMetadata( - { recipient }, + { recipient, tags: [] }, masterKey, ); - // upload to server - /* - payload = { - "type": "SENT", - "status": "SEALED", - "encrypted_content": "enc_content==", - "encrypted_metadata": "enc_metadata==", - "encrypted_dek": "enc_dek==", - "image_files": [image1, image2], - } - */ const formData = new FormData(); formData.append("public_id", letterIdRef.current); - formData.append("type", "SENT"); - formData.append("status", "SEALED"); + 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); @@ -125,30 +218,51 @@ export default function Editor() { } finally { setIsSealing(false); } - } + }; return ( -
- {isSaveSuccess && ( -
-
-
- -

Your letter is sealed!

-
- {/*
- -
*/} +
+ {isInitialLoading && ( +
+
+ +

+ Opening your draft... +

)} -
+ {isSaveSuccess && ( +
+
+ +

+ Your letter is saved! +

+
+
+ )} + {isSealing && ( +
+
+ +

+ Securing your letter... +

+
+
+ )} +