refactor: lint formatting and fixes #6

Merged
me merged 10 commits from refactor/lint_fixes into main 2026-05-08 06:13:25 +00:00
37 changed files with 332 additions and 291 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -19
View File
@@ -1,19 +1 @@
{
"name": "Pi. Ku.",
"short_name": "Pi. Ku.",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#d4a24f",
"background_color": "#3b1d13",
"display": "standalone"
}
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function App() {
return (
<BrowserRouter>
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/noise.gif')]">
<main className="relative min-h-screen min-w-screen flex items-center justify-center w-full bg-base-200 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-50 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]">
<Suspense fallback={<SplashScreen />}>
<Routes>
<Route
+24
View File
@@ -0,0 +1,24 @@
export interface LetterResponseData {
public_id: string;
type: "KEPT" | "SENT" | "VAULT";
status: "DRAFT" | "SEALED" | "BURNED";
encrypted_content: string;
encrypted_metadata: string;
encrypted_dek: string;
unlock_at: string | null;
sealed_at: string | null;
created_at: string;
updated_at: string;
images: LetterImageData[];
}
export interface LetterImageData {
public_id: string;
file: string;
file_name: string;
}
export interface LetterMetadata {
recipient: string;
tags?: string[];
}

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Before

Width:  |  Height:  |  Size: 738 KiB

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

+2 -6
View File
@@ -1,4 +1,5 @@
import { DotIcon } from "@phosphor-icons/react";
import logo from "../assets/logo.svg";
import "@fontsource/knewave/400.css";
interface LogoProps {
@@ -31,12 +32,7 @@ export default function Logo({
if (type === "logo") {
return (
<img
src="/logo.svg"
alt="Pi. Ku. logo"
className="mx-4"
width={scale * 100}
/>
<img src={logo} alt="Pi. Ku. logo" className="mx-4" width={scale * 100} />
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export default function SplashScreen() {
return (
<div
data-testid="splash-screen"
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')"
className="fixed w-screen h-screen inset-0 flex flex-col items-center justify-center z-9999 before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')"
>
<div className="flex flex-col items-center gap-6 animate-pulse">
<Logo />
@@ -66,7 +66,7 @@ export function WelcomeLetterOverlay({
type="button"
data-testid="dismiss-welcome-letter-btn"
onClick={onComplete}
className="btn btn-accent opacity-80 px-12 shadow-lg"
className="btn btn-base btn-xs btn-wide opacity-80 shadow-lg font-light tracking-wider"
>
I'll see you
</button>
@@ -122,7 +122,7 @@ export function ComposeCanvas({
// re-calculates height based on content and applies the zoom transform
const syncViewport = useCallback(() => {
if (!(fabricRef.current && wrapperRef.current)) return;
textboxRef.current.initDimensions();
textboxRef.current?.initDimensions();
const minHeight = initialData?.canvasHeight ?? DEFAULT_LOGICAL_HEIGHT;
logicalSizeRef.current.height = measureLogicalContentHeight(
@@ -260,17 +260,35 @@ export function ComposeCanvas({
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
const getInitialWidth = async () => {
if (!wrapperRef.current) return BASE_WIDTH;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
return width;
};
const initResizeOberver = () => {
if (!wrapperRef.current) return null;
const observer = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
observer.observe(wrapperRef.current);
return observer;
};
const initCanvas = async () => {
// HACK: actual font may change the text-width - small ux improvement
await document.fonts.ready;
if (!(wrapperRef.current && canvasRef.current && isMounted)) return;
let width = wrapperRef.current.clientWidth;
if (width === 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
width = wrapperRef.current?.clientWidth || BASE_WIDTH;
}
const width = await getInitialWidth();
// init the fabric instance
const canvas = new fabric.Canvas(canvasRef.current, {
@@ -301,13 +319,7 @@ export function ComposeCanvas({
// auto window resizing based width
lastWidth = wrapperRef.current.clientWidth;
resizeObserver = new ResizeObserver(() => {
const nextWidth = wrapperRef.current?.clientWidth;
if (!nextWidth || nextWidth === lastWidth) return;
lastWidth = nextWidth;
syncViewport();
});
resizeObserver.observe(wrapperRef.current!);
resizeObserver = initResizeOberver();
};
initCanvas().then();
@@ -63,7 +63,11 @@ export function PostSealModal({
type="button"
data-testid="view-letter-btn"
className="btn btn-primary btn-sm"
onClick={() => navigate(PATHS.read(sealedTargetId!))}
onClick={() => {
if (sealedTargetId) {
navigate(PATHS.read(sealedTargetId));
}
}}
>
View letter
</button>
+1 -1
View File
@@ -11,7 +11,7 @@ import {
XCircleIcon,
} from "@phosphor-icons/react";
import { Modal } from "../ui/Modal";
import type { CanvasStyle } from "./ComposeCanvas.tsx";
import type { CanvasStyle } from "./ComposeCanvas";
interface ToolBarProps {
onAddImage: () => void;
@@ -3,9 +3,9 @@ import {
ShieldCheckIcon,
WarningIcon,
} from "@phosphor-icons/react";
import Logo from "../Logo.tsx";
import Logo from "../Logo";
import { Modal } from "../ui/Modal";
import Saajan from "../ui/Saajan.tsx";
import Saajan from "../ui/Saajan";
export default function WelcomeModal({
setShowWelcome,
@@ -53,7 +53,7 @@ export default function WelcomeModal({
href="https://www.privacyguides.org/en/passwords/"
target="_blank"
className="link link-primary-content"
rel="noopener"
rel="noopener noreferrer"
>
password manager
</a>{" "}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Modal({
return (
<div
data-testid={testId}
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/noise.gif')]"
className="modal modal-open modal-middle backdrop-blur-md before:absolute before:top-0 before:left-0 before:w-full before:h-full before:content-[''] before:opacity-[0.03] before:z-10 before:pointer-events-none before:bg-[url('assets/textures/noise.gif')]"
>
<div className="modal-box relative bg-base-100/60 flex flex-col items-center text-center gap-6">
{onClose && (
+4 -3
View File
@@ -1,3 +1,4 @@
import trainImage from "../assets/screenshots/train.png";
import type { CanvasJSON } from "../components/editor/ComposeCanvas";
export function getWelcomeLetterContent(userName: string): CanvasJSON {
@@ -30,7 +31,7 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
originY: "top",
left: 36,
top: 36,
width: 608,
width: 720,
height: 813.6,
fill: "#111e67",
stroke: null,
@@ -90,12 +91,12 @@ export function getWelcomeLetterContent(userName: string): CanvasJSON {
globalCompositeOperation: "source-over",
skewX: 0,
skewY: 0,
src: "/screenshots/train.png",
src: trainImage,
crossOrigin: null,
filters: [],
},
],
canvasWidth: 680,
canvasWidth: 700,
canvasHeight: 900,
};
}
+1 -1
View File
@@ -32,7 +32,7 @@ export const useAuth = () => {
const logout = async () => {
try {
await api.post(endpoints.LOGOUT);
} catch (_error) {
} catch {
} finally {
clearAuth();
setMasterKey(null);
+4 -20
View File
@@ -1,32 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api/apiClient";
import type { LetterMetadata, LetterResponseData } from "../api/response";
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 {
export interface ProcessedLetter extends LetterResponseData {
metadata: LetterMetadata;
}
async function decryptLettersMetadata(
letters: Letter[],
letters: LetterResponseData[],
masterKey: CryptoKey,
): Promise<ProcessedLetter[]> {
const cryptoUtils = new CryptoUtils();
@@ -43,7 +27,7 @@ async function decryptLettersMetadata(
)) as LetterMetadata;
return { ...letter, metadata };
} catch (_err) {
} catch {
return {
...letter,
metadata: { recipient: "Encrypted Letter" },
+1 -1
View File
@@ -7,7 +7,7 @@ import "@fontsource-variable/playwrite-hr-lijeva/wght.css";
import "@fontsource-variable/jost/wght.css";
import "@fontsource-variable/playfair-display/wght.css";
import App from "./App.tsx";
import App from "./App";
const root = document.getElementById("root");
if (root) {
+12 -14
View File
@@ -25,7 +25,9 @@ import { ReactLenis } from "lenis/react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import { useEffect, useRef, useState } from "react";
import stamp from "../assets/envelope/stamp.png";
import Logo from "../components/Logo.tsx";
import e2eDiag from "../assets/screenshots/e2e.svg";
import saajan from "../assets/sf.png";
import Logo from "../components/Logo";
import { Modal } from "../components/ui/Modal";
import "@fontsource/kavivanar/index.css";
@@ -35,7 +37,7 @@ import "@fontsource/architects-daughter/index.css";
import { useNavigate } from "react-router-dom";
function HorizontalScroll({ children }: { children: React.ReactNode }) {
const ref = useRef(null);
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: ref });
const x = useTransform(scrollYProgress, [0, 1], ["0%", "-50%"]);
@@ -166,7 +168,7 @@ function PrivacySection() {
</div>
</div>
<div className="diff-item-2" role="img">
<div className="bg-neutral-content bg-[url('https://www.transparenttextures.com/patterns/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="bg-neutral-content bg-[url('assets/textures/random-grey-variations.png')] text-primary-content grid place-content-center text-sm md:gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-3xl md:text-6xl uppercase font-bold text-right tracking-widest mt-2 md:mt-8">
server see
@@ -257,7 +259,7 @@ function SpecsSection() {
</a>{" "}
for the <span className="font-hand text-primary">keys</span>.
</h2>
<p className="text-sm md:text-xl leading-relaxed">
<div className="text-sm md:text-xl leading-relaxed">
This means, both the{" "}
<span className="font-display text-info">encryption</span> and{" "}
<span className="font-display text-info">decryption</span> runs on
@@ -288,8 +290,8 @@ function SpecsSection() {
<span className="italic">only you</span>&mdash;hold the very thing
that opens that box,{" "}
<span className="font-mono text-accent">your password</span>.
</p>
<p className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80">
</div>
<div className="text-xs md:text-lg text-right w-full flex items-center justify-end gap-4 leading-relaxed text-neutral-content/80">
<span>
Nothing on the server is readable without your actual password.
<br />
@@ -304,14 +306,14 @@ function SpecsSection() {
(unless this happens)
</a>
</span>
<div className="w-18 h-18 flex shrink-0 items-center justify-center bg-success/20 rounded-full p-0 ">
<div className="w-18 h-18 flex shrink-0 items-center justify-end bg-success/20 rounded-full p-0 ">
<VaultIcon
size={36}
weight="duotone"
className="text-neutral-content"
/>
</div>
</p>
</div>
<button
type={"button"}
@@ -326,11 +328,7 @@ function SpecsSection() {
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<div className="w-full bg-paper rounded-md p-6">
<img
src="/screenshots/e2e.svg"
width={"100%"}
alt="pi ku e2e diagram"
/>
<img src={e2eDiag} width={"100%"} alt="pi ku e2e diagram" />
</div>
</Modal>
@@ -833,7 +831,7 @@ function AttributionSection() {
<AnimatePresence>
{hover.visible && (
<motion.img
src="/saajan.png"
src={saajan}
alt="Saajan Fernandes from The Lunchbox, cutout"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
+1 -1
View File
@@ -26,7 +26,7 @@ export default function Activate() {
});
await publicApi.get(url);
setStatus("success");
} catch (_err) {
} catch {
setStatus("error");
}
};
+6 -6
View File
@@ -7,19 +7,19 @@ import {
} from "@phosphor-icons/react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { DrawerSection } from "../components/drawer/DrawerSection.tsx";
import { LetterItem } from "../components/drawer/LetterItem.tsx";
import { PasskeyModal } from "../components/drawer/PasskeyModal.tsx";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay.tsx";
import { DrawerSection } from "../components/drawer/DrawerSection";
import { LetterItem } from "../components/drawer/LetterItem";
import { PasskeyModal } from "../components/drawer/PasskeyModal";
import { WelcomeLetterOverlay } from "../components/drawer/WelcomeLetterOverlay";
import Logo from "../components/Logo";
import Saajan from "../components/ui/Saajan.tsx";
import Saajan from "../components/ui/Saajan";
import { PATHS } from "../config/routes";
import { useAuth } from "../hooks/useAuth";
import { useLetters } from "../hooks/useLetters";
import {
formatRelativeDate,
formatRelativeDateWithoutTime,
} from "../utils/dateFormat.ts";
} from "../utils/dateFormat";
export default function Drawer() {
const { user, logout } = useAuth();
+111 -105
View File
@@ -11,6 +11,7 @@ import {
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterResponseData } from "../api/response";
import {
type CanvasStyle,
type CanvasTools,
@@ -26,7 +27,6 @@ import DateDisplay from "../components/ui/DateDisplay";
import { LogModal } from "../components/ui/LogModal";
import { Modal } from "../components/ui/Modal";
import { Navbar } from "../components/ui/Navbar";
import { endpoints } from "../config/endpoints";
import { PATHS } from "../config/routes";
import { useKeyStore } from "../store/useKeyStore";
@@ -116,10 +116,54 @@ export default function Editor() {
justSavedRef.current = false;
return;
}
const decryptAndLoadLetter = async (
letterData: LetterResponseData,
masterKey: CryptoKey,
) => {
const cryptoUtils = new CryptoUtils();
const metadata = await cryptoUtils.decryptMetadata(
{
encrypted_content: letterData.encrypted_metadata,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
setRecipient(metadata.recipient || "");
const decryptedJsonStr = await cryptoUtils.decryptLetter(
{
encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
await decryptCanvasImages(
canvasData,
letterData.images ?? [],
letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
if (isPartialFailure) {
setDecryptionStatus({
status: "WARN",
message: "Failed to decrypt some elements. Please check the render.",
log: errors.toString(),
});
}
if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
}
};
const loadExistingLetter = async () => {
setIsInitialLoading(true);
const cryptoUtils = new CryptoUtils();
try {
const res = await api.get(`${endpoints.LETTERS}${public_id}/`);
const letterData = res.data;
@@ -132,55 +176,14 @@ export default function Editor() {
return;
}
if (!letterData.encrypted_dek) {
return;
if (letterData.encrypted_dek && masterKey) {
await decryptAndLoadLetter(letterData, masterKey);
}
const metadata = await cryptoUtils.decryptMetadata(
{
encrypted_content: letterData.encrypted_metadata,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
setRecipient(metadata.recipient || "");
const decryptedJsonStr = await cryptoUtils.decryptLetter(
{
encrypted_content: letterData.encrypted_content,
encrypted_dek: letterData.encrypted_dek,
},
masterKey,
);
const canvasData = JSON.parse(decryptedJsonStr);
const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
await decryptCanvasImages(
canvasData,
letterData.images ?? [],
letterData.encrypted_dek,
masterKey,
cryptoUtils,
true,
);
if (isPartialFailure) {
setDecryptionStatus({
status: "WARN",
message:
"Failed to decrypt some elements. Please check the render.",
log: errors.toString(),
});
}
if (canvasRef.current) {
await canvasRef.current.loadData(canvasDataWithDecryptedImages);
}
} catch (_err) {
} catch (err) {
setDecryptionStatus({
status: "ERROR",
message: "Failed to decrypt letter. Please try again later.",
log: _err instanceof Error ? _err.message : "Unknown error",
log: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsInitialLoading(false);
@@ -242,76 +245,79 @@ export default function Editor() {
}
};
const getRequestData = async (
targetId: string,
status: string,
vaultDate?: Date,
): Promise<FormData> => {
const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize();
const canvasData = (await canvasRef.current?.getData()) || { objects: [] };
const canvasImages = canvasRef.current?.getImages() || [];
const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages(
canvasData,
canvasImages,
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
cryptoUtils,
);
const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData),
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] },
// biome-ignore lint/style/noNonNullAssertion: masterkey can never be null here
masterKey!,
);
const formData = new FormData();
if (status === "VAULT") {
const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT");
if (finalDate) formData.append("unlock_at", finalDate.toISOString());
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT");
formData.append("status", status);
}
formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append("encrypted_metadata", encrypted_metadata.encrypted_content);
encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename);
});
return formData;
};
const handleSave = async (
status: "SEALED" | "DRAFT" | "VAULT",
vaultDate?: Date,
): Promise<void> => {
setSealBtnClicked(false);
let targetId = public_id || letterIdRef.current;
if (!targetId) {
targetId = crypto.randomUUID();
}
// use the letter's id if an existing letter or create a new id
const targetId = public_id || letterIdRef.current || crypto.randomUUID();
if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("SAVING");
setShowSaveOverlay(true);
const cryptoUtils = new CryptoUtils();
await cryptoUtils.initialize();
try {
const canvasData = (await canvasRef.current?.getData()) || {
objects: [],
};
const canvasImages = canvasRef.current?.getImages() || [];
const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages(
canvasData,
canvasImages,
masterKey,
cryptoUtils,
);
const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(encryptedCanvasData),
masterKey,
);
const encrypted_metadata = await cryptoUtils.encryptMetadata(
{ recipient, tags: [] },
masterKey,
);
const formData = new FormData();
if (status === "VAULT") {
const finalDate = vaultDate || unlockDate;
formData.append("type", "VAULT");
if (finalDate) {
formData.append("unlock_at", finalDate.toISOString());
}
formData.append("status", "SEALED");
} else {
formData.append("type", "KEPT");
formData.append("status", status);
}
formData.append("public_id", targetId);
formData.append("encrypted_content", encrypted_letter.encrypted_content);
formData.append("encrypted_dek", encrypted_letter.encrypted_dek);
formData.append(
"encrypted_metadata",
encrypted_metadata.encrypted_content,
);
encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename);
});
const formData = await getRequestData(targetId, status, vaultDate);
await api.put(`${endpoints.LETTERS}${targetId}/`, formData);
justSavedRef.current = true;
justSavedRef.current = true;
if (!public_id) {
letterIdRef.current = targetId;
navigate(PATHS.write(targetId), { replace: true });
@@ -326,7 +332,7 @@ export default function Editor() {
}
setSaveOverlay("SAVED");
setShowSaveOverlay(true);
} catch (_error) {
} catch {
setSaveOverlay("ERROR");
setShowSaveOverlay(true);
}
+5 -4
View File
@@ -8,11 +8,12 @@ import {
} from "motion/react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import letterSample from "../assets/screenshots/letter.webp";
import Logo from "../components/Logo";
import { EnvelopeReveal } from "../components/reader/EnvelopeReveal";
import Saajan from "../components/ui/Saajan.tsx";
import { ROUTES } from "../config/routes.ts";
import { formatDate } from "../utils/dateFormat.ts";
import Saajan from "../components/ui/Saajan";
import { ROUTES } from "../config/routes";
import { formatDate } from "../utils/dateFormat";
import "@fontsource/space-mono/index.css";
import "@fontsource/architects-daughter/index.css";
@@ -325,7 +326,7 @@ export default function Home() {
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" />
<img alt="letter" src={letterSample} />
</div>
</div>
</motion.div>
+110 -84
View File
@@ -1,4 +1,5 @@
import { FlameIcon, PaperPlaneTiltIcon } from "@phosphor-icons/react";
import type { AxiosResponse } from "axios";
import { useEffect, useRef, useState } from "react";
import {
type NavigateFunction,
@@ -7,6 +8,7 @@ import {
useParams,
} from "react-router-dom";
import { api } from "../api/apiClient";
import type { LetterImageData, LetterResponseData } from "../api/response";
import {
type CanvasJSON,
type CanvasTools,
@@ -71,7 +73,8 @@ export default function Reader() {
const key = await cryptoUtils.extractSharingKey(encryptedDek, masterKey);
try {
await api.patch(`${endpoints.LETTERS}${public_id}/`, { type: "SENT" });
} catch (_err) {
} catch {
// shouldn't obstruct share if api operation fails (since it's client side share)
} finally {
setShareLink(`${window.location.origin}${PATHS.read(public_id)}#${key}`);
}
@@ -84,7 +87,10 @@ export default function Reader() {
await api.patch(`${endpoints.LETTERS}${public_id}/`, {
status: "BURNED",
});
} catch (_err) {
} catch {
// should not obstruct burn if api operation fails
// WHY?: it disconnects the UX. if you want to burn the letter, you should be able to burn the letter
// TODO: maybe say something like: "the wind is strong today, let's try again"? or maybe something less stupid :3
} finally {
setIsBurning(false);
setShowBurnModal(false);
@@ -103,89 +109,109 @@ export default function Reader() {
return;
}
const loadAndDecrypt = async () => {
const decryptImages = async (
canvasData: CanvasJSON,
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
const isShared = !!sharingKey;
try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
status,
} = response.data;
if (status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
const cryptoUtils = new CryptoUtils();
const isShared = !!sharingKey;
if (isShared && !encrypted_content) throw new Error("Content missing");
const isDecryptionKeyAvailable = encrypted_dek && masterKey;
if (!(isShared || isDecryptionKeyAvailable))
throw new Error("Auth required: Decryption key is not available");
// Decrypt Metadata
const decryptedMetadata = isShared
? await cryptoUtils.decryptMetadataWithSharingKey(
encrypted_metadata,
sharingKey,
)
: await cryptoUtils.decryptMetadata(
{ encrypted_content: encrypted_metadata, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
setMetadata({
...(decryptedMetadata as LetterMetadata),
updated_at,
});
// Decrypt Content
const decryptedContent = isShared
? await cryptoUtils.decryptLetterWithSharingKey(
encrypted_content,
sharingKey,
)
: await cryptoUtils.decryptLetter(
{ encrypted_content, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
try {
// Decrypt Images
if (images?.length > 0) {
isShared
? await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
)
: await decryptCanvasImages(
canvasData,
images,
encrypted_dek,
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
cryptoUtils,
);
}
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
if (isShared) {
await decryptCanvasImagesWithSharingKey(
canvasData,
images,
sharingKey,
cryptoUtils,
);
} else {
await decryptCanvasImages(
canvasData,
images,
encrypted_dek,
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
cryptoUtils,
);
}
setDecryptedCanvasData(canvasData);
} catch (err) {
setLogTrace({
message:
"Failed to decrypt elements. Images might not render in the letter as intended.",
log: err instanceof Error ? err.message : "Unknown error",
type: "WARN",
});
}
};
const decryptLetterData = async (
data: LetterResponseData,
cryptoUtils: CryptoUtils,
) => {
const isShared = !!sharingKey;
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
} = data;
// Decrypt Metadata
const decryptedMetadata = isShared
? await cryptoUtils.decryptMetadataWithSharingKey(
encrypted_metadata,
sharingKey,
)
: await cryptoUtils.decryptMetadata(
{ encrypted_content: encrypted_metadata, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
setMetadata({
...(decryptedMetadata as LetterMetadata),
updated_at,
});
// Decrypt Content
const decryptedContent = isShared
? await cryptoUtils.decryptLetterWithSharingKey(
encrypted_content,
sharingKey,
)
: await cryptoUtils.decryptLetter(
{ encrypted_content, encrypted_dek },
// biome-ignore lint/style/noNonNullAssertion: masterKey is guaranteed to be non-null here as isDecryptionKeyAvailable is true
masterKey!,
);
const canvasData: CanvasJSON = JSON.parse(decryptedContent);
await decryptImages(canvasData, images, encrypted_dek, cryptoUtils);
setDecryptedCanvasData(canvasData);
};
const processLetterData = async (data: LetterResponseData) => {
if (data.status === "BURNED")
throw new Error("This letter has been burned.");
if (data.encrypted_dek) setEncryptedDek(data.encrypted_dek);
const isDecryptionKeyAvailable = data.encrypted_dek && masterKey;
if (!(!!sharingKey || isDecryptionKeyAvailable)) {
throw new Error("Auth required: Decryption key is not available");
}
const cryptoUtils = new CryptoUtils();
await decryptLetterData(data, cryptoUtils);
};
const loadAndDecryptLetter = async () => {
try {
const response: AxiosResponse<LetterResponseData> = await api.get(
`${endpoints.LETTERS}${public_id}/`,
);
await processLetterData(response.data);
} catch (err) {
setLogTrace({
message: `Failed to load letter ☹`,
@@ -195,7 +221,7 @@ export default function Reader() {
}
};
loadAndDecrypt().then(() => setIsDecrypting(false));
loadAndDecryptLetter().then(() => setIsDecrypting(false));
}, [public_id, sharingKey, masterKey]);
useEffect(() => {
+5 -3
View File
@@ -1,3 +1,5 @@
import type { LetterMetadata } from "../api/response";
export interface EncryptedLetter {
encrypted_content: string;
encrypted_dek: string;
@@ -275,7 +277,7 @@ export class CryptoUtils {
}
public async encryptMetadata(
metadata: Record<string, any>,
metadata: LetterMetadata,
masterKey: CryptoKey,
): Promise<EncryptedLetterMetadata> {
const { encryptedContent, encrypted_dek, sharingKey } =
@@ -290,7 +292,7 @@ export class CryptoUtils {
public async decryptMetadata(
encrypted_metadata: EncryptedLetter,
masterKey: CryptoKey,
): Promise<Record<string, any>> {
): Promise<LetterMetadata> {
const bytes = await this.openEnvelope(
encrypted_metadata.encrypted_content,
encrypted_metadata.encrypted_dek,
@@ -303,7 +305,7 @@ export class CryptoUtils {
public async decryptMetadataWithSharingKey(
encrypted_content: string,
sharingKey: string,
): Promise<Record<string, any>> {
): Promise<LetterMetadata> {
const bytes = await this.openEnvelopeWithSharingKey(
encrypted_content,
sharingKey,
+5 -1
View File
@@ -221,7 +221,11 @@ describe("letterLogic image helpers", () => {
],
};
const remoteImages = [
{ file_name: "photo.png.bin", file: "https://remote/photo.png.bin" },
{
public_id: "1234",
file_name: "photo.png.bin",
file: "https://remote/photo.png.bin",
},
];
vi.mocked(api.get).mockResolvedValue({ data: new Blob(["encrypted"]) });
+2 -1
View File
@@ -1,4 +1,5 @@
import { api, apiServerUrl, publicApi } from "../api/apiClient";
import type { LetterImageData } from "../api/response";
import type {
CanvasJSON,
FabricImageJSON,
@@ -111,7 +112,7 @@ export async function decryptCanvasImages(
export async function decryptCanvasImagesWithSharingKey(
canvasData: CanvasJSON,
remoteImages: { file_name: string; file: string }[],
remoteImages: LetterImageData[],
sharingKey: string,
cryptoUtils: CryptoUtils,
): Promise<DecryptionResult> {