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