refactor: simplify letterlogic by removing object mutation

This commit is contained in:
ramvignesh-b
2026-04-29 23:09:32 +05:30
parent b9716d368d
commit df96cead93
5 changed files with 266 additions and 164 deletions
+9 -10
View File
@@ -2,19 +2,19 @@ import axios from "axios";
import { endpoints } from "../config/endpoints"; import { endpoints } from "../config/endpoints";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
export const apiServerUrl = import.meta.env.VITE_API_URL;
// publicApi for endpoints that don't need authentication (login, refresh, register) // publicApi for endpoints that don't need authentication (login, refresh, register)
export const publicApi = axios.create({ export const publicApi = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: apiServerUrl,
withCredentials: true, withCredentials: true,
}); });
// api for all authenticated requests // api for all authenticated requests
export const api = axios.create({ export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL, baseURL: apiServerUrl,
withCredentials: true, withCredentials: true,
}); });
// auto-attach access token to authenticated requests
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken; const token = useAuthStore.getState().accessToken;
if (token) { if (token) {
@@ -22,29 +22,28 @@ api.interceptors.request.use((config) => {
} }
return config; return config;
}); });
// auto handle 401 errors by attempting a silent refresh
// Handle 401 errors by attempting a silent refresh
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
// If 401 and we haven't tried refreshing yet // if first time 401 and we haven't tried refreshing yet, we proceed with silent refresh
// else it could mean the refresh also 401'd
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
try { try {
// Attempt silent refresh
const { data } = await publicApi.post(endpoints.REFRESH); const { data } = await publicApi.post(endpoints.REFRESH);
const newAccessToken = data.access; const newAccessToken = data.access;
// Update store // Update store with the latest accesstoken
const { user, setAuth } = useAuthStore.getState(); const { user, setAuth } = useAuthStore.getState();
if (user) { if (user) {
setAuth(newAccessToken, user); setAuth(newAccessToken, user);
} }
// Retry the original request with the new token // retry the original request with the new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest); return api(originalRequest);
} catch (refreshError) { } catch (refreshError) {
+12 -3
View File
@@ -25,7 +25,7 @@ export interface ProcessedLetter extends Letter {
metadata: LetterMetadata; metadata: LetterMetadata;
} }
async function decryptLetters( async function decryptLettersMetadata(
letters: Letter[], letters: Letter[],
masterKey: CryptoKey, masterKey: CryptoKey,
): Promise<ProcessedLetter[]> { ): Promise<ProcessedLetter[]> {
@@ -56,19 +56,22 @@ async function decryptLetters(
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 [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
useEffect(() => { useEffect(() => {
if (!masterKey) { if (!masterKey) {
setIsAuthRequired(true); setIsAuthRequired(true);
return; return;
} }
setIsAuthRequired(false); setIsAuthRequired(false);
setError(null);
setLoading(true); setLoading(true);
api api
.get(endpoints.LETTERS) .get(endpoints.LETTERS)
.then((res) => decryptLetters(res.data, masterKey)) .then((res) => decryptLettersMetadata(res.data, masterKey))
.then((decrypted) => { .then((decrypted) => {
setLetters( setLetters(
decrypted.sort( decrypted.sort(
@@ -78,7 +81,9 @@ export function useLetters() {
), ),
); );
}) })
.catch((_err) => {}) .catch((err) => {
setError(err);
})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [masterKey]); }, [masterKey]);
@@ -91,6 +96,10 @@ export function useLetters() {
}; };
}, [letters]); }, [letters]);
if (error) {
throw error;
}
return { return {
...drawerItems, ...drawerItems,
loading, loading,
+58 -30
View File
@@ -33,11 +33,12 @@ import { CryptoUtils } from "../utils/crypto";
import { formatRelativeDate } from "../utils/dateFormat"; import { formatRelativeDate } from "../utils/dateFormat";
import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic"; import { decryptCanvasImages, encryptCanvasImages } from "../utils/letterLogic";
type SaveOverlay = "idle" | "saving" | "saved" | "error"; type SaveOverlay = "IDLE" | "SAVING" | "SAVED" | "ERROR";
const OVERLAY_FADE_MS = 250; const OVERLAY_FADE_MS = 250;
const SAVED_VISIBLE_MS = 1400; const SAVED_VISIBLE_MS = 1400;
const ERROR_VISIBLE_MS = 2400; const ERROR_VISIBLE_MS = 2400;
const STOP_SAVE_DATE_PULSE_AFTER_MS = 10000;
const toPlaceholderList = [ const toPlaceholderList = [
"Someone dear...", "Someone dear...",
@@ -45,6 +46,7 @@ const toPlaceholderList = [
"Something to bear...", "Something to bear...",
]; ];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
export default function Editor() { export default function Editor() {
const navigate = useNavigate(); const navigate = useNavigate();
const navigateRef = useRef<NavigateFunction>(navigate); const navigateRef = useRef<NavigateFunction>(navigate);
@@ -70,7 +72,14 @@ export default function Editor() {
const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0); const [lastSavedPulseTick, setLastSavedPulseTick] = useState(0);
const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false); const [sealBtnClicked, setSealBtnClicked] = useState<boolean>(false);
const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("idle"); const [saveOverlay, setSaveOverlay] = useState<SaveOverlay>("IDLE");
const [logStatus, setLogStatus] = useState<{
status: "WARN" | "ERROR" | "RESET";
message: string;
}>({
status: "RESET",
message: "",
});
const [showSaveOverlay, setShowSaveOverlay] = useState(false); const [showSaveOverlay, setShowSaveOverlay] = useState(false);
const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>( const [confirmModal, setConfirmModal] = useState<"VAULT" | "SEAL" | null>(
null, null,
@@ -85,7 +94,7 @@ export default function Editor() {
const canvasRef = useRef<CanvasTools>(null); const canvasRef = useRef<CanvasTools>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// Placeholder rotation // to continuously rotate placeholder text of the recipient input
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length); setPlaceholderIndex((prev) => (prev + 1) % toPlaceholderList.length);
@@ -94,13 +103,14 @@ export default function Editor() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// to load existing letter when public_id param and masterKey is available
// NOTE: this has to trigger just once after each save
useEffect(() => { useEffect(() => {
if (!(public_id && masterKey)) return; if (!(public_id && masterKey)) return;
if (justSavedRef.current) { if (justSavedRef.current) {
justSavedRef.current = false; justSavedRef.current = false;
return; return;
} }
const loadExistingLetter = async () => { const loadExistingLetter = async () => {
setIsInitialLoading(true); setIsInitialLoading(true);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
@@ -139,7 +149,8 @@ export default function Editor() {
); );
const canvasData = JSON.parse(decryptedJsonStr); const canvasData = JSON.parse(decryptedJsonStr);
const { isDecryptionPartialFailure, error } = await decryptCanvasImages( const { errors, isPartialFailure, canvasDataWithDecryptedImages } =
await decryptCanvasImages(
canvasData, canvasData,
letterData.images ?? [], letterData.images ?? [],
letterData.encrypted_dek, letterData.encrypted_dek,
@@ -148,17 +159,17 @@ export default function Editor() {
true, true,
); );
if (isDecryptionPartialFailure) { 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: error, log: errors.toString(),
}); });
} }
if (canvasRef.current) { if (canvasRef.current) {
await canvasRef.current.loadData(canvasData); await canvasRef.current.loadData(canvasDataWithDecryptedImages);
} }
} catch (_err) { } catch (_err) {
setDecryptionStatus({ setDecryptionStatus({
@@ -170,37 +181,36 @@ export default function Editor() {
setIsInitialLoading(false); setIsInitialLoading(false);
} }
}; };
loadExistingLetter(); loadExistingLetter();
}, [public_id, masterKey]); }, [public_id, masterKey]);
// to trigger short pulse animation for Last Saved AT element
useEffect(() => { useEffect(() => {
if (lastSavedPulseTick === 0) return; if (lastSavedPulseTick === 0) return;
setIsSaveDatePulsing(true); setIsSaveDatePulsing(true);
const timer = setTimeout(() => { const timer = setTimeout(() => {
setIsSaveDatePulsing(false); setIsSaveDatePulsing(false);
}, 10000); }, STOP_SAVE_DATE_PULSE_AFTER_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [lastSavedPulseTick]); }, [lastSavedPulseTick]);
// to fade in and fade out the save status overlay after each save operation
// Note: otherwise the fade efect is abrupt due to component's immediate unmount
useEffect(() => { useEffect(() => {
if (saveOverlay === "idle" || saveOverlay === "saving") return; if (saveOverlay === "IDLE" || saveOverlay === "SAVING") return;
const visibleTimer = setTimeout( const visibleTimer = setTimeout(
() => { () => {
setShowSaveOverlay(false); setShowSaveOverlay(false);
}, },
saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS, saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS,
); );
const unmountTimer = setTimeout( const unmountTimer = setTimeout(
() => { () => {
setSaveOverlay("idle"); setSaveOverlay("IDLE");
}, },
(saveOverlay === "saved" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) + (saveOverlay === "SAVED" ? SAVED_VISIBLE_MS : ERROR_VISIBLE_MS) +
OVERLAY_FADE_MS, OVERLAY_FADE_MS,
); );
@@ -212,9 +222,14 @@ export default function Editor() {
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file && file.size < MAX_FILE_SIZE) {
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
canvasRef.current?.addImage(url, file); canvasRef.current?.addImage(url, file);
} else {
setLogStatus({
status: "WARN",
message: "Please upload images with size less than 10MB.",
});
} }
}; };
@@ -229,9 +244,9 @@ export default function Editor() {
targetId = crypto.randomUUID(); targetId = crypto.randomUUID();
} }
if (saveOverlay === "saving" || !masterKey) return; if (saveOverlay === "SAVING" || !masterKey) return;
setSaveOverlay("saving"); setSaveOverlay("SAVING");
setShowSaveOverlay(true); setShowSaveOverlay(true);
const cryptoUtils = new CryptoUtils(); const cryptoUtils = new CryptoUtils();
@@ -241,7 +256,8 @@ export default function Editor() {
const canvasData = canvasRef.current?.getData() || { objects: [] }; const canvasData = canvasRef.current?.getData() || { objects: [] };
const canvasImages = canvasRef.current?.getImages() || []; const canvasImages = canvasRef.current?.getImages() || [];
const encImageFilesMap = await encryptCanvasImages( const { encryptedImageFiles, encryptedCanvasData } =
await encryptCanvasImages(
canvasData, canvasData,
canvasImages, canvasImages,
masterKey, masterKey,
@@ -249,7 +265,7 @@ export default function Editor() {
); );
const encrypted_letter = await cryptoUtils.encryptLetter( const encrypted_letter = await cryptoUtils.encryptLetter(
JSON.stringify(canvasData), JSON.stringify(encryptedCanvasData),
masterKey, masterKey,
); );
@@ -278,7 +294,7 @@ export default function Editor() {
encrypted_metadata.encrypted_content, encrypted_metadata.encrypted_content,
); );
encImageFilesMap.forEach((blob, filename) => { encryptedImageFiles.forEach((blob, filename) => {
formData.append("image_files", blob, filename); formData.append("image_files", blob, filename);
}); });
@@ -297,10 +313,10 @@ export default function Editor() {
if (status === "SEALED" || status === "VAULT") { if (status === "SEALED" || status === "VAULT") {
setSealedTargetId(targetId); setSealedTargetId(targetId);
} }
setSaveOverlay("saved"); setSaveOverlay("SAVED");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} catch (_error) { } catch (_error) {
setSaveOverlay("error"); setSaveOverlay("ERROR");
setShowSaveOverlay(true); setShowSaveOverlay(true);
} }
}; };
@@ -356,9 +372,9 @@ export default function Editor() {
</div> </div>
)} )}
{saveOverlay !== "idle" && ( {saveOverlay !== "IDLE" && (
<Modal isOpen={showSaveOverlay}> <Modal isOpen={showSaveOverlay}>
{saveOverlay === "saving" && ( {saveOverlay === "SAVING" && (
<div <div
role="alert" role="alert"
className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${ className={`alert text-center alert-neutral shadow-lg transition-all ease-in-out duration-2000 ${
@@ -376,7 +392,7 @@ export default function Editor() {
</div> </div>
)} )}
{saveOverlay === "saved" && ( {saveOverlay === "SAVED" && (
<div <div
role="alert" role="alert"
className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${ className={`alert alert-success shadow-lg transition-all ease-in-out duration-2000 ${
@@ -390,7 +406,7 @@ export default function Editor() {
</div> </div>
)} )}
{saveOverlay === "error" && ( {saveOverlay === "ERROR" && (
<div <div
role="alert" role="alert"
className={`alert alert-error shadow-lg transition-all duration-300 ${ className={`alert alert-error shadow-lg transition-all duration-300 ${
@@ -426,7 +442,7 @@ export default function Editor() {
<div className="flex flex-col gap-2 flex-1"> <div className="flex flex-col gap-2 flex-1">
<label <label
htmlFor="recipient" htmlFor="recipient"
className="text-[10px] uppercase tracking-[0.4em] text-secondary-content font-bold" className="text-xs uppercase tracking-[0.4em] text-secondary-content font-bold"
> >
Recipient Recipient
</label> </label>
@@ -466,6 +482,18 @@ export default function Editor() {
<ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} /> <ComposeCanvas ref={canvasRef} readOnly={status !== "DRAFT"} />
</div> </div>
</section> </section>
<LogModal
status={logStatus.status}
message={logStatus.message}
log={""}
onClose={() =>
setLogStatus({
status: "RESET",
message: "",
})
}
isOpen={logStatus.status !== "RESET"}
/>
</> </>
); );
} }
+22 -23
View File
@@ -12,6 +12,7 @@ vi.mock("../api/apiClient", () => ({
api: { api: {
get: vi.fn(), get: vi.fn(),
}, },
apiServerUrl: "https://remote",
})); }));
vi.mock("./fileUtils", () => ({ vi.mock("./fileUtils", () => ({
@@ -21,7 +22,6 @@ vi.mock("./fileUtils", () => ({
describe("letterLogic image helpers", () => { describe("letterLogic image helpers", () => {
let masterKey: CryptoKey; let masterKey: CryptoKey;
let crypto: CryptoUtils; let crypto: CryptoUtils;
beforeEach(async () => { beforeEach(async () => {
const keyBundle = await CryptoUtils.deriveKeyBundle( const keyBundle = await CryptoUtils.deriveKeyBundle(
"password123", "password123",
@@ -58,15 +58,13 @@ describe("letterLogic image helpers", () => {
const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage"); const encryptImageSpy = vi.spyOn(CryptoUtils.prototype, "encryptImage");
const uploads = await encryptCanvasImages( const { encryptedImageFiles: uploads, encryptedCanvasData } =
canvasData, await encryptCanvasImages(canvasData, [], masterKey, crypto);
[],
masterKey,
crypto,
);
expect(encryptImageSpy).not.toHaveBeenCalled(); expect(encryptImageSpy).not.toHaveBeenCalled();
expect(canvasData.objects[0].src).toBe("already-encrypted.png.bin"); expect(encryptedCanvasData.objects[0].src).toBe(
"already-encrypted.png.bin",
);
expect(uploads.size).toBe(0); expect(uploads.size).toBe(0);
}); });
@@ -99,15 +97,11 @@ describe("letterLogic image helpers", () => {
filename: "photo.png.bin", filename: "photo.png.bin",
}); });
const uploads = await encryptCanvasImages( const { encryptedImageFiles: uploads, encryptedCanvasData } =
canvasData, await encryptCanvasImages(canvasData, canvasImages, masterKey, crypto);
canvasImages,
masterKey,
crypto,
);
expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1); expect(CryptoUtils.prototype.encryptImage).toHaveBeenCalledTimes(1);
expect(canvasData.objects[0].src).toBe("photo.png.bin"); expect(encryptedCanvasData.objects[0].src).toBe("photo.png.bin");
expect(uploads.size).toBe(1); expect(uploads.size).toBe(1);
expect(uploads.has("photo.png.bin")).toBe(true); expect(uploads.has("photo.png.bin")).toBe(true);
}); });
@@ -136,7 +130,7 @@ describe("letterLogic image helpers", () => {
], ],
}; };
const remoteImages = [ const remoteImages = [
{ file_name: "photo.png.bin", file: "https://remote/photo.png.bin" }, { 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"]) });
@@ -144,7 +138,7 @@ describe("letterLogic image helpers", () => {
"blob:http://localhost/decrypted", "blob:http://localhost/decrypted",
); );
await decryptCanvasImages( const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
canvasData, canvasData,
remoteImages, remoteImages,
"wrapped-dek", "wrapped-dek",
@@ -153,7 +147,7 @@ describe("letterLogic image helpers", () => {
); );
expect(api.get).toHaveBeenCalledWith( expect(api.get).toHaveBeenCalledWith(
"https://remote/photo.png.bin", `https://remote/photo.png.bin`,
expect.objectContaining({ responseType: "blob" }), expect.objectContaining({ responseType: "blob" }),
); );
expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith( expect(CryptoUtils.prototype.decryptImage).toHaveBeenCalledWith(
@@ -161,8 +155,10 @@ describe("letterLogic image helpers", () => {
"wrapped-dek", "wrapped-dek",
masterKey, masterKey,
); );
expect(canvasData.objects[0].src).toBe("blob:http://localhost/decrypted"); expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
expect(canvasData.objects[1].text).toBe("hello"); "blob:http://localhost/decrypted",
);
expect(canvasDataWithDecryptedImages.objects[1].text).toBe("hello");
}); });
it("should include raw file when includeRawFile is true", async () => { it("should include raw file when includeRawFile is true", async () => {
@@ -191,7 +187,7 @@ describe("letterLogic image helpers", () => {
new File(["raw"], "photo.png.bin"), new File(["raw"], "photo.png.bin"),
); );
await decryptCanvasImages( const { canvasDataWithDecryptedImages } = await decryptCanvasImages(
canvasData, canvasData,
remoteImages, remoteImages,
"wrapped-dek", "wrapped-dek",
@@ -204,7 +200,9 @@ describe("letterLogic image helpers", () => {
"blob:http://localhost/decrypted", "blob:http://localhost/decrypted",
"photo.png.bin", "photo.png.bin",
); );
expect(canvasData.objects[0]._customRawFile).toBeInstanceOf(File); expect(
canvasDataWithDecryptedImages.objects[0]._customRawFile,
).toBeInstanceOf(File);
}); });
}); });
@@ -232,6 +230,7 @@ describe("letterLogic image helpers", () => {
"decryptImageWithSharingKey", "decryptImageWithSharingKey",
).mockResolvedValue("blob:http://localhost/decrypted-shared"); ).mockResolvedValue("blob:http://localhost/decrypted-shared");
const { canvasDataWithDecryptedImages } =
await decryptCanvasImagesWithSharingKey( await decryptCanvasImagesWithSharingKey(
canvasData, canvasData,
remoteImages, remoteImages,
@@ -246,7 +245,7 @@ describe("letterLogic image helpers", () => {
expect( expect(
CryptoUtils.prototype.decryptImageWithSharingKey, CryptoUtils.prototype.decryptImageWithSharingKey,
).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key"); ).toHaveBeenCalledWith(expect.any(Blob), "raw-sharing-key");
expect(canvasData.objects[0].src).toBe( expect(canvasDataWithDecryptedImages.objects[0].src).toBe(
"blob:http://localhost/decrypted-shared", "blob:http://localhost/decrypted-shared",
); );
}); });
+130 -63
View File
@@ -1,4 +1,4 @@
import { api } from "../api/apiClient"; import { api, apiServerUrl, publicApi } from "../api/apiClient";
import type { import type {
CanvasJSON, CanvasJSON,
FabricImageJSON, FabricImageJSON,
@@ -11,6 +11,35 @@ export interface CanvasImageRef {
file: File; file: File;
} }
export interface DecryptedFabricImageJSON extends FabricImageJSON {
_customRawFile?: File;
}
export interface DecryptionResult {
canvasDataWithDecryptedImages: CanvasJSON;
isPartialFailure: boolean;
errors: string[];
}
export interface EncryptionResult {
encryptedImageFiles: Map<string, Blob>;
encryptedCanvasData: CanvasJSON;
}
async function fetchEncryptedBlobFromRemote(remoteUrl: string): Promise<Blob> {
// IF served statically from server, we need proper CORS setup
if (remoteUrl.includes(apiServerUrl)) {
const res = await api.get(remoteUrl, { responseType: "blob" });
return res.data;
}
// Note: S3 Storage fetch (external url) has to bypass our existing CORS setup
const res = await publicApi.get(remoteUrl, {
responseType: "blob",
withCredentials: false,
});
return res.data;
}
export async function decryptCanvasImages( export async function decryptCanvasImages(
canvasData: CanvasJSON, canvasData: CanvasJSON,
remoteImages: { file_name: string; file: string }[], remoteImages: { file_name: string; file: string }[],
@@ -18,51 +47,66 @@ export async function decryptCanvasImages(
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
includeRawFile = false, includeRawFile = false,
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> { ): Promise<DecryptionResult> {
if (!canvasData?.objects) if (!canvasData?.objects) {
return { isDecryptionPartialFailure: false, error: "" }; return {
let isDecryptionPartialFailure = false; canvasDataWithDecryptedImages: canvasData,
let error = ""; isPartialFailure: false,
errors: [],
};
}
const imageMap = new Map( const imageMap = new Map(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
const imageDecryptionPromises = canvasData.objects.map(async (obj, index) => { const errors: string[] = [];
if (obj.type !== "Image") return; const processedObjects = await Promise.all(
canvasData.objects.map(async (obj) => {
if (obj.type !== "Image") return obj;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
const remoteUrl = imageMap.get(imgObj.src); const remoteUrl = imageMap.get(imgObj.src);
if (!remoteUrl) return; if (!remoteUrl) return obj;
try { try {
// HACK: For S3 Storage fetch and avoiding CORS error const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
const res = await api.get(remoteUrl, {
responseType: "blob",
withCredentials: false,
});
const originalSrc = imgObj.src;
const blobUrl = await cryptoUtils.decryptImage( const blobUrl = await cryptoUtils.decryptImage(
res.data, blob,
encrypted_dek, encrypted_dek,
masterKey, masterKey,
); );
imgObj.src = blobUrl; const decryptedObj: DecryptedFabricImageJSON = {
...imgObj,
src: blobUrl,
};
if (includeRawFile) { if (includeRawFile) {
imgObj._customRawFile = await blobUrlToFile(blobUrl, originalSrc); decryptedObj._customRawFile = await blobUrlToFile(
blobUrl,
imgObj.src,
);
} }
} catch (_error) {
delete canvasData.objects[index];
isDecryptionPartialFailure = true;
error = _error instanceof Error ? _error.message : "Unknown error";
}
});
await Promise.all(imageDecryptionPromises); return decryptedObj;
canvasData.objects = canvasData.objects.filter(Boolean); } catch (err) {
return { isDecryptionPartialFailure, error }; errors.push(
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
return null;
}
}),
);
return {
canvasDataWithDecryptedImages: {
...canvasData,
objects: processedObjects.filter((obj) => !!obj),
},
isPartialFailure: errors.length > 0,
errors,
};
} }
export async function decryptCanvasImagesWithSharingKey( export async function decryptCanvasImagesWithSharingKey(
@@ -70,41 +114,53 @@ export async function decryptCanvasImagesWithSharingKey(
remoteImages: { file_name: string; file: string }[], remoteImages: { file_name: string; file: string }[],
sharingKey: string, sharingKey: string,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
): Promise<{ isDecryptionPartialFailure: boolean; error: string }> { ): Promise<DecryptionResult> {
if (!canvasData?.objects) if (!canvasData?.objects) {
return { isDecryptionPartialFailure: false, error: "" }; return {
let isDecryptionPartialFailure = false; canvasDataWithDecryptedImages: canvasData,
let error = ""; isPartialFailure: false,
errors: [],
};
}
const imageMap = new Map( const imageMap = new Map(
remoteImages.map((img) => [img.file_name, img.file]), remoteImages.map((img) => [img.file_name, img.file]),
); );
const errors: string[] = [];
const decryptionPromises = canvasData.objects.map(async (obj, index) => { const processedObjects = await Promise.all(
if (obj.type !== "Image") return; canvasData.objects.map(async (obj) => {
if (obj.type !== "Image") return obj;
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
const remoteUrl = imageMap.get(imgObj.src); const remoteUrl = imageMap.get(imgObj.src);
if (!remoteUrl) return; if (!remoteUrl) return obj;
try { try {
const res = await api.get(remoteUrl, { const blob = await fetchEncryptedBlobFromRemote(remoteUrl);
responseType: "blob", const blobUrl = await cryptoUtils.decryptImageWithSharingKey(
withCredentials: false, blob,
});
imgObj.src = await cryptoUtils.decryptImageWithSharingKey(
res.data,
sharingKey, sharingKey,
); );
} catch (_error) {
delete canvasData.objects[index];
isDecryptionPartialFailure = true;
error = _error instanceof Error ? _error.message : "Unknown error";
}
});
await Promise.all(decryptionPromises); return { ...imgObj, src: blobUrl };
canvasData.objects = canvasData.objects.filter(Boolean); } catch (err) {
return { isDecryptionPartialFailure, error }; errors.push(
`Failed to decrypt ${imgObj.src}: ${err instanceof Error ? err.message : "Unknown error"}`,
);
return null;
}
}),
);
return {
canvasDataWithDecryptedImages: {
...canvasData,
objects: processedObjects.filter((obj) => !!obj),
},
isPartialFailure: errors.length > 0,
errors,
};
} }
export async function encryptCanvasImages( export async function encryptCanvasImages(
@@ -112,23 +168,34 @@ export async function encryptCanvasImages(
canvasImages: CanvasImageRef[], canvasImages: CanvasImageRef[],
masterKey: CryptoKey, masterKey: CryptoKey,
cryptoUtils: CryptoUtils, cryptoUtils: CryptoUtils,
) { ): Promise<EncryptionResult> {
const encryptedFiles = new Map<string, Blob>(); const encryptedImageFiles = new Map<string, Blob>();
const filenameMapping = new Map<string, string>(); const filenameMapping = new Map<string, string>();
for (const img of canvasImages) { // filter out already encrypted images
if (img.src.endsWith(".bin")) continue; const imagesToEncrypt = canvasImages.filter(
if (!img.file) continue; (img) => img.file && !img.src.endsWith(".bin"),
);
// encrypt images parallelly
await Promise.all(
imagesToEncrypt.map(async (img) => {
const { filename, encryptedBlob } = await cryptoUtils.encryptImage( const { filename, encryptedBlob } = await cryptoUtils.encryptImage(
img.file, img.file,
masterKey, masterKey,
); );
// map the og image url to the encrypted file name and filename to the encrypted source
filenameMapping.set(img.src, filename); filenameMapping.set(img.src, filename);
encryptedFiles.set(filename, encryptedBlob); encryptedImageFiles.set(filename, encryptedBlob);
} }),
);
if (canvasData?.objects) { if (!canvasData?.objects)
canvasData.objects = canvasData.objects.map((obj) => { return { encryptedImageFiles, encryptedCanvasData: canvasData };
const newCanvasData = {
...canvasData,
objects: canvasData.objects.map((obj) => {
if (obj.type === "Image") { if (obj.type === "Image") {
const imgObj = obj as FabricImageJSON; const imgObj = obj as FabricImageJSON;
if (filenameMapping.has(imgObj.src)) { if (filenameMapping.has(imgObj.src)) {
@@ -139,8 +206,8 @@ export async function encryptCanvasImages(
} }
} }
return obj; return obj;
}); }),
} };
return encryptedFiles; return { encryptedImageFiles, encryptedCanvasData: newCanvasData };
} }