|
@@ -23,9 +23,11 @@ import {
|
|
|
const LOCAL_STORAGE_KEY = "excalidraw";
|
|
|
const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
|
|
|
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
|
|
-const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/";
|
|
|
const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
|
|
|
|
|
|
+const BACKEND_V2_POST = "https://json.excalidraw.com/api/v2/post/";
|
|
|
+const BACKEND_V2_GET = "https://json.excalidraw.com/api/v2/";
|
|
|
+
|
|
|
// TODO: Defined globally, since file handles aren't yet serializable.
|
|
|
// Once `FileSystemFileHandle` can be serialized, make this
|
|
|
// part of `AppState`.
|
|
@@ -146,25 +148,54 @@ export async function exportToBackend(
|
|
|
elements: readonly ExcalidrawElement[],
|
|
|
appState: AppState,
|
|
|
) {
|
|
|
- let response;
|
|
|
+ const json = serializeAsJSON(elements, appState);
|
|
|
+ const encoded = new TextEncoder().encode(json);
|
|
|
+
|
|
|
+ const key = await window.crypto.subtle.generateKey(
|
|
|
+ {
|
|
|
+ name: "AES-GCM",
|
|
|
+ length: 128,
|
|
|
+ },
|
|
|
+ true, // extractable
|
|
|
+ ["encrypt", "decrypt"],
|
|
|
+ );
|
|
|
+ // The iv is set to 0. We are never going to reuse the same key so we don't
|
|
|
+ // need to have an iv. (I hope that's correct...)
|
|
|
+ const iv = new Uint8Array(12);
|
|
|
+ // We use symmetric encryption. AES-GCM is the recommended algorithm and
|
|
|
+ // includes checks that the ciphertext has not been modified by an attacker.
|
|
|
+ const encrypted = await window.crypto.subtle.encrypt(
|
|
|
+ {
|
|
|
+ name: "AES-GCM",
|
|
|
+ iv: iv,
|
|
|
+ },
|
|
|
+ key,
|
|
|
+ encoded,
|
|
|
+ );
|
|
|
+ // We use jwk encoding to be able to extract just the base64 encoded key.
|
|
|
+ // We will hardcode the rest of the attributes when importing back the key.
|
|
|
+ const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
|
|
|
+
|
|
|
try {
|
|
|
- response = await fetch(BACKEND_POST, {
|
|
|
+ const response = await fetch(BACKEND_V2_POST, {
|
|
|
method: "POST",
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
- body: serializeAsJSON(elements, appState),
|
|
|
+ body: encrypted,
|
|
|
});
|
|
|
const json = await response.json();
|
|
|
+ // TODO: comment following
|
|
|
+ // const json = {id: '1234'}
|
|
|
+ // console.log("new Uint8Array([" + new Uint8Array(encrypted).join(",") + "])");
|
|
|
+
|
|
|
if (json.id) {
|
|
|
const url = new URL(window.location.href);
|
|
|
- url.searchParams.append("id", json.id);
|
|
|
+ // We need to store the key (and less importantly the id) as hash instead
|
|
|
+ // of queryParam in order to never send it to the server
|
|
|
+ url.hash = `json=${json.id},${exportedKey.k!}`;
|
|
|
+ const urlString = url.toString();
|
|
|
|
|
|
try {
|
|
|
- await copyTextToSystemClipboard(url.toString());
|
|
|
- window.alert(
|
|
|
- t("alerts.copiedToClipboard", {
|
|
|
- url: url.toString(),
|
|
|
- }),
|
|
|
- );
|
|
|
+ await copyTextToSystemClipboard(urlString);
|
|
|
+ window.alert(t("alerts.copiedToClipboard", { url: urlString }));
|
|
|
} catch (err) {
|
|
|
// TODO: link will be displayed for user to copy manually in later PR
|
|
|
}
|
|
@@ -172,31 +203,73 @@ export async function exportToBackend(
|
|
|
window.alert(t("alerts.couldNotCreateShareableLink"));
|
|
|
}
|
|
|
} catch (e) {
|
|
|
+ console.error(e);
|
|
|
window.alert(t("alerts.couldNotCreateShareableLink"));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-export async function importFromBackend(id: string | null) {
|
|
|
+export async function importFromBackend(
|
|
|
+ id: string | null,
|
|
|
+ k: string | undefined,
|
|
|
+) {
|
|
|
let elements: readonly ExcalidrawElement[] = [];
|
|
|
let appState: AppState = getDefaultAppState();
|
|
|
- const data = await fetch(`${BACKEND_GET}${id}.json`)
|
|
|
- .then(response => {
|
|
|
- if (!response.ok) {
|
|
|
- window.alert(t("alerts.importBackendFailed"));
|
|
|
- }
|
|
|
- return response;
|
|
|
- })
|
|
|
- .then(response => response.clone().json());
|
|
|
- if (data != null) {
|
|
|
- try {
|
|
|
- elements = data.elements || elements;
|
|
|
- appState = data.appState || appState;
|
|
|
- } catch (error) {
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await fetch(
|
|
|
+ k ? `${BACKEND_V2_GET}${id}` : `${BACKEND_GET}${id}.json`,
|
|
|
+ );
|
|
|
+ if (!response.ok) {
|
|
|
window.alert(t("alerts.importBackendFailed"));
|
|
|
- console.error(error);
|
|
|
+ return restore(elements, appState, { scrollToContent: true });
|
|
|
+ }
|
|
|
+ let data;
|
|
|
+ if (k) {
|
|
|
+ const buffer = await response.arrayBuffer();
|
|
|
+ const key = await window.crypto.subtle.importKey(
|
|
|
+ "jwk",
|
|
|
+ {
|
|
|
+ alg: "A128GCM",
|
|
|
+ ext: true,
|
|
|
+ k: k,
|
|
|
+ key_ops: ["encrypt", "decrypt"],
|
|
|
+ kty: "oct",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "AES-GCM",
|
|
|
+ length: 128,
|
|
|
+ },
|
|
|
+ false, // extractable
|
|
|
+ ["decrypt"],
|
|
|
+ );
|
|
|
+ const iv = new Uint8Array(12);
|
|
|
+ const decrypted = await window.crypto.subtle.decrypt(
|
|
|
+ {
|
|
|
+ name: "AES-GCM",
|
|
|
+ iv: iv,
|
|
|
+ },
|
|
|
+ key,
|
|
|
+ buffer,
|
|
|
+ );
|
|
|
+ // We need to convert the decrypted array buffer to a string
|
|
|
+ const string = String.fromCharCode.apply(
|
|
|
+ null,
|
|
|
+ new Uint8Array(decrypted) as any,
|
|
|
+ );
|
|
|
+ data = JSON.parse(string);
|
|
|
+ } else {
|
|
|
+ // Legacy format
|
|
|
+ data = await response.json();
|
|
|
}
|
|
|
+
|
|
|
+ elements = data.elements || elements;
|
|
|
+ appState = data.appState || appState;
|
|
|
+ } catch (error) {
|
|
|
+ window.alert(t("alerts.importBackendFailed"));
|
|
|
+ console.error(error);
|
|
|
+ } finally {
|
|
|
+ return restore(elements, appState, { scrollToContent: true });
|
|
|
}
|
|
|
- return restore(elements, appState, { scrollToContent: true });
|
|
|
}
|
|
|
|
|
|
export async function exportCanvas(
|
|
@@ -394,7 +467,7 @@ export function loadedScenes(): PreviousScene[] {
|
|
|
* Append id to the list of Previous Scenes in Local Storage if not there yet
|
|
|
* @param id string
|
|
|
*/
|
|
|
-export function addToLoadedScenes(id: string): void {
|
|
|
+export function addToLoadedScenes(id: string, k: string | undefined): void {
|
|
|
const scenes = [...loadedScenes()];
|
|
|
const newScene = scenes.every(scene => scene.id !== id);
|
|
|
|
|
@@ -402,6 +475,7 @@ export function addToLoadedScenes(id: string): void {
|
|
|
scenes.push({
|
|
|
timestamp: Date.now(),
|
|
|
id,
|
|
|
+ k,
|
|
|
});
|
|
|
}
|
|
|
|