refactor: add type interfaces for api data
This commit is contained in:
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,108 +1,92 @@
|
|||||||
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;
|
metadata: LetterMetadata;
|
||||||
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 decryptLettersMetadata(
|
async function decryptLettersMetadata(
|
||||||
letters: Letter[],
|
letters: LetterResponseData[],
|
||||||
masterKey: CryptoKey,
|
masterKey: CryptoKey,
|
||||||
): Promise<ProcessedLetter[]> {
|
): Promise<ProcessedLetter[]> {
|
||||||
const cryptoUtils = new CryptoUtils();
|
const cryptoUtils = new CryptoUtils();
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
letters.map(async (letter) => {
|
letters.map(async (letter) => {
|
||||||
try {
|
try {
|
||||||
const metadata = (await cryptoUtils.decryptMetadata(
|
const metadata = (await cryptoUtils.decryptMetadata(
|
||||||
{
|
{
|
||||||
encrypted_content: letter.encrypted_metadata,
|
encrypted_content: letter.encrypted_metadata,
|
||||||
encrypted_dek: letter.encrypted_dek,
|
encrypted_dek: letter.encrypted_dek,
|
||||||
},
|
},
|
||||||
masterKey,
|
masterKey,
|
||||||
)) 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" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLetters() {
|
export function useLetters() {
|
||||||
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
const [letters, setLetters] = useState<ProcessedLetter[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
const [isAuthRequired, setIsAuthRequired] = useState<boolean>(false);
|
||||||
const { masterKey } = useKeyStore();
|
const { masterKey } = useKeyStore();
|
||||||
|
|
||||||
// to fetch the letters and decryypt the metadata on load
|
// to fetch the letters and decryypt the metadata on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!masterKey) {
|
if (!masterKey) {
|
||||||
setIsAuthRequired(true);
|
setIsAuthRequired(true);
|
||||||
return;
|
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 {
|
return {
|
||||||
drafts: letters.filter((l) => l.status === "DRAFT"),
|
...drawerItems,
|
||||||
kept: letters.filter((l) => l.type === "KEPT" && l.status === "SEALED"),
|
loading,
|
||||||
vault: letters.filter((l) => l.type === "VAULT" && l.status === "SEALED"),
|
isAuthRequired,
|
||||||
sent: letters.filter((l) => l.type === "SENT" && l.status === "SEALED"),
|
|
||||||
};
|
};
|
||||||
}, [letters]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...drawerItems,
|
|
||||||
loading,
|
|
||||||
isAuthRequired,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -103,89 +105,107 @@ export default function Reader() {
|
|||||||
return;
|
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 () => {
|
const loadAndDecrypt = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`${endpoints.LETTERS}${public_id}/`);
|
const response: AxiosResponse<LetterResponseData> = await api.get(
|
||||||
const {
|
`${endpoints.LETTERS}${public_id}/`,
|
||||||
encrypted_content,
|
);
|
||||||
encrypted_metadata,
|
const data = response.data;
|
||||||
encrypted_dek,
|
|
||||||
images,
|
|
||||||
updated_at,
|
|
||||||
status,
|
|
||||||
} = response.data;
|
|
||||||
|
|
||||||
if (status === "BURNED")
|
if (data.status === "BURNED")
|
||||||
throw new Error("This letter has been 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 cryptoUtils = new CryptoUtils();
|
||||||
const isShared = !!sharingKey;
|
await decryptLetterData(data, cryptoUtils);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogTrace({
|
setLogTrace({
|
||||||
message: `Failed to load letter ☹`,
|
message: `Failed to load letter ☹`,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user