refactor: add type interfaces for api data

This commit is contained in:
me
2026-05-08 10:29:07 +05:30
parent 55583255bc
commit 26cf95c78b
6 changed files with 203 additions and 168 deletions
+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[];
}
+71 -87
View File
@@ -1,108 +1,92 @@
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 {
metadata: LetterMetadata;
export interface ProcessedLetter extends LetterResponseData {
metadata: LetterMetadata;
}
async function decryptLettersMetadata(
letters: Letter[],
masterKey: CryptoKey,
letters: LetterResponseData[],
masterKey: CryptoKey,
): Promise<ProcessedLetter[]> {
const cryptoUtils = new CryptoUtils();
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 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) {
return {
...letter,
metadata: { recipient: "Encrypted Letter" },
};
}
}),
);
return { ...letter, metadata };
} catch {
return {
...letter,
metadata: { recipient: "Encrypted Letter" },
};
}
}),
);
}
export function useLetters() {
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
const { masterKey } = useKeyStore();
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
const { masterKey } = useKeyStore();
// to fetch the letters and decryypt the metadata on load
useEffect(() => {
if (!masterKey) {
setIsAuthRequired(true);
return;
// to fetch the letters and decryypt the metadata on load
useEffect(() => {
if (!masterKey) {
setIsAuthRequired(true);
return;
}
setIsAuthRequired(false);
setError(null);
setLoading(true);
api
.get(endpoints.LETTERS)
.then((res) => decryptLettersMetadata(res.data, masterKey))
.then((decrypted) => {
setLetters(
decrypted.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
),
);
})
.catch((err) => {
setError(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" && l.status === "SEALED"),
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
};
}, [letters]);
if (error) {
throw error;
}
setIsAuthRequired(false);
setError(null);
setLoading(true);
api
.get(endpoints.LETTERS)
.then((res) => decryptLettersMetadata(res.data, masterKey))
.then((decrypted) => {
setLetters(
decrypted.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
),
);
})
.catch((err) => {
setError(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" && l.status === "SEALED"),
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
...drawerItems,
loading,
isAuthRequired,
};
}, [letters]);
if (error) {
throw error;
}
return {
...drawerItems,
loading,
isAuthRequired,
};
}
+96 -76
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,
@@ -103,89 +105,107 @@ export default function Reader() {
return;
}
const decryptImages = async (
canvasData: CanvasJSON,
images: LetterImageData[],
encrypted_dek: string,
cryptoUtils: CryptoUtils,
) => {
if (!images?.length) return;
const isShared = !!sharingKey;
try {
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 {
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 loadAndDecrypt = async () => {
try {
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
const {
encrypted_content,
encrypted_metadata,
encrypted_dek,
images,
updated_at,
status,
} = response.data;
const response: AxiosResponse<LetterResponseData> = await api.get(
`${endpoints.LETTERS}${public_id}/`,
);
const data = response.data;
if (status === "BURNED")
if (data.status === "BURNED")
throw new Error("This letter has been burned.");
if (encrypted_dek) setEncryptedDek(encrypted_dek);
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();
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",
});
}
setDecryptedCanvasData(canvasData);
await decryptLetterData(data, cryptoUtils);
} catch (err) {
setLogTrace({
message: `Failed to load letter ☹`,
+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> {