mirror of
https://github.com/ramvignesh-b/pi-ku.git
synced 2026-05-04 08:56:52 +00:00
refactor: simplify letterlogic by removing object mutation
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user