removed firebase and added support for rooms without firebase and fix docker setup

This commit is contained in:
Jannik Streek 2024-01-01 18:05:48 +01:00
parent d19b51d4f8
commit ad571b12b8
21 changed files with 585 additions and 80 deletions

View File

@ -4,8 +4,9 @@
!.eslintrc.json
!.npmrc
!.prettierrc
!excalidraw-app/
!package.json
!public/
!packages/
!tsconfig.json
!yarn.lock
!yarn.lock

1
.env.default Normal file
View File

@ -0,0 +1 @@
REDIS_PASSWORD=CHANGE_ME

View File

@ -1,11 +1,14 @@
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
VITE_APP_BACKEND_V2_GET_URL=http://localhost:8080/api/v2/scenes/
VITE_APP_BACKEND_V2_POST_URL=http://localhost:8080/api/v2/scenes/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
VITE_APP_STORAGE_BACKEND=http
VITE_APP_HTTP_STORAGE_BACKEND_URL=http://localhost:8080/api/v2
# collaboration WebSocket server (https://github.com/excalidraw/excalidraw-room)
VITE_APP_WS_SERVER_URL=http://localhost:3002
VITE_APP_WS_SERVER_URL=http://localhost:5001
# set this only if using the collaboration workflow we use on excalidraw.com
VITE_APP_PORTAL_URL=
@ -13,9 +16,9 @@ VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=http://localhost:3015
VITE_APP_AI_BACKEND=
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'
# put these in your .env.local, or make sure you don't commit!
# must be lowercase `true` when turned on
@ -41,4 +44,4 @@ VITE_APP_DEBUG_ENABLE_TEXT_CONTAINER_BOUNDING_BOX=
VITE_APP_COLLAPSE_OVERLAY=true
# Set this flag to false to disable eslint
VITE_APP_ENABLE_ESLINT=true
VITE_APP_ENABLE_ESLINT=true

View File

@ -1,20 +1,24 @@
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
VITE_APP_BACKEND_V2_GET_URL=http://localhost:8080/api/v2/scenes/
VITE_APP_BACKEND_V2_POST_URL=http://localhost:8080/api/v2/scenes/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
VITE_APP_PORTAL_URL=https://portal.excalidraw.com
VITE_APP_STORAGE_BACKEND=http
VITE_APP_HTTP_STORAGE_BACKEND_URL=http://localhost:5011/api/v2
VITE_APP_PORTAL_URL=
VITE_APP_PLUS_LP=https://plus.excalidraw.com
VITE_APP_PLUS_APP=https://app.excalidraw.com
VITE_APP_AI_BACKEND=https://oss-ai.excalidraw.com
VITE_APP_AI_BACKEND=
# Fill to set socket server URL used for collaboration.
# Meant for forks only: excalidraw.com uses custom VITE_APP_PORTAL_URL flow
VITE_APP_WS_SERVER_URL=
VITE_APP_WS_SERVER_URL=http://localhost:5012
VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
# VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
VITE_APP_DISABLE_TRACKING=
VITE_APP_DISABLE_TRACKING=true
VITE_APP_DISABLE_SENTRY=true

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.DS_Store
.env.development.local
.env.local
.env
.env.production.local
.env.test.local
.envrc

View File

@ -2,16 +2,23 @@ FROM node:18 AS build
WORKDIR /opt/node_app
COPY package.json yarn.lock ./
RUN yarn --ignore-optional --network-timeout 600000
FROM build as production_buildstage
ARG NODE_ENV=production
COPY package.json yarn.lock ./
COPY excalidraw-app/package.json ./excalidraw-app/
COPY packages/excalidraw/package.json ./packages/excalidraw/
RUN yarn --network-timeout 600000
COPY . .
ARG NODE_ENV=production
RUN yarn build:app:docker
FROM nginx:1.21-alpine
FROM nginx:1.21-alpine as production
COPY --from=build /opt/node_app/build /usr/share/nginx/html
COPY --from=production_buildstage /opt/node_app/excalidraw-app/build /usr/share/nginx/html
HEALTHCHECK CMD wget -q -O /dev/null http://localhost || exit 1
FROM build as development

View File

@ -1,23 +1,55 @@
<a href="https://excalidraw.com/" target="_blank" rel="noopener">
<picture>
<source media="(prefers-color-scheme: dark)" alt="Excalidraw" srcset="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/github/excalidraw_github_cover_2_dark.png" />
<img alt="Excalidraw" src="https://excalidraw.nyc3.cdn.digitaloceanspaces.com/github/excalidraw_github_cover_2.png" />
</picture>
</a>
# Excalidraw without firebase
<h4 align="center">
<a href="https://excalidraw.com">Excalidraw Editor</a> |
<a href="https://blog.excalidraw.com">Blog</a> |
<a href="https://docs.excalidraw.com">Documentation</a> |
<a href="https://plus.excalidraw.com">Excalidraw+</a>
</h4>
This is a fork from the [excalidraw project](https://github.com/excalidraw/excalidraw) with changes inspired and partly taken from [Kilian Decaderincourt](https://gitlab.com/kiliandeca/excalidraw-fork) to enable support for rooms without using firebase.<br />
<div align="center">
<h2>
An open source virtual hand-drawn style whiteboard. </br>
Collaborative and end-to-end encrypted. </br>
<br />
</h2>
## Setup with docker
Please copy the .env.development.default or .env.production.default file to .env (or with environment without default at the end) and change it according to your needs, see [react-scripts](https://create-react-app.dev/docs/adding-custom-environment-variables/).
### Development
```
docker-compose up -d
docker-compose exec excalidraw yarn install
docker-compose exec excalidraw yarn start
```
Hint: Collab mode requires a secure context (https). Localhost works as well, but not http over local network.
#### Commands
| Command | Description |
| ------------------ | --------------------------------- |
| `yarn` | Install the dependencies |
| `yarn start` | Run the project |
| `yarn fix` | Reformat all files with Prettier |
| `yarn test` | Run tests |
| `yarn test:update` | Update test snapshots |
| `yarn test:code` | Test for formatting with Prettier |
### Production
```
docker-compose -f docker-compose-prod.yml up -d
```
## Additional licence
The excalidraw [logo](https://thenounproject.com/icon/2357486/) in this repo created by [Verry](https://thenounproject.com/verry.dsign.creative) is licenced under [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/).
<div align="center" style="display:flex;flex-direction:column;"}>
<a href="https://excalidraw.com">
<img width="540" src="./public/og-image-sm.png" alt="Excalidraw logo: Sketch handrawn like diagrams."/>
</a>
<h3>Virtual whiteboard for sketching hand-drawn like diagrams.<br/>Collaborative and end-to-end encrypted.</h3>
<p>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>
<a href="https://discord.gg/UexuTaE">
<img alt="Chat with us on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
</p>
</div>
<br />
@ -127,3 +159,14 @@ If you like the project, you can become a sponsor at [Open Collective](https://o
Last but not least, we're thankful to these companies for offering their services for free:
[![Vercel](./.github/assets/vercel.svg)](https://vercel.com) [![Sentry](./.github/assets/sentry.svg)](https://sentry.io) [![Crowdin](./.github/assets/crowdin.svg)](https://crowdin.com)
## Developers
You can integrate Excalidraw into your app by installing our [npm component](https://npmjs.com/package/@excalidraw/excalidraw).
Visit our documentation on [https://docs.excalidraw.com](https://docs.excalidraw.com).
## Who's integrating Excalidraw
[Google Cloud](https://googlecloudcheatsheet.withgoogle.com/architecture) • [Meta](https://meta.com/) • [CodeSandbox](https://codesandbox.io/) • [Obsidian Excalidraw](https://github.com/zsviczian/obsidian-excalidraw-plugin) • [Replit](https://replit.com/) • [Slite](https://slite.com/) • [Notion](https://notion.so/) • [HackerRank](https://www.hackerrank.com/)
```

44
docker-compose-prod.yml Normal file
View File

@ -0,0 +1,44 @@
version: "3.8"
services:
excalidraw:
stdin_open: true
build:
context: .
target: production
container_name: excalidraw
ports:
- "5010:80"
restart: always
healthcheck:
disable: true
environment:
- NODE_ENV=production
excalidraw-storage-backend:
build:
context: https://github.com/kitsteam/excalidraw-storage-backend.git#main
target: production
ports:
- "5011:8080"
restart: always
environment:
STORAGE_URI: redis://:${REDIS_PASSWORD}@redis:6379
STORAGE_TTL: 2592000000
excalidraw-room:
image: excalidraw/excalidraw-room
restart: always
ports:
- "5012:80"
redis:
image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
restart: always
volumes:
- redis_data:/data
volumes:
notused:
redis_data:

View File

@ -2,24 +2,43 @@ version: "3.8"
services:
excalidraw:
stdin_open: true
build:
context: .
args:
- NODE_ENV=development
target: development
container_name: excalidraw
ports:
- "3000:80"
- "3000:3000"
restart: on-failure
stdin_open: true
healthcheck:
disable: true
environment:
- NODE_ENV=development
volumes:
- ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json
- ./yarn.lock:/opt/node_app/yarn.lock
- notused:/opt/node_app/app/node_modules
- node_modules:/opt/node_app/node_modules
- ./:/opt/node_app/
excalidraw-storage-backend:
stdin_open: true
build:
context: https://github.com/kitsteam/excalidraw-storage-backend.git#main
target: production
ports:
- "8080:8080"
environment:
STORAGE_URI: redis://:${REDIS_PASSWORD}@redis:6379
STORAGE_TTL: 2592000000
excalidraw-room:
image: excalidraw/excalidraw-room
ports:
- "5001:80"
redis:
image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
volumes:
notused:
redis_data:
node_modules:

View File

@ -97,6 +97,7 @@ import { AppFooter } from "./components/AppFooter";
import { atom, Provider, useAtom, useAtomValue } from "jotai";
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
import { appJotaiStore } from "./app-jotai";
import { getStorageBackend } from "./data/config";
import "./index.scss";
import { ResolutionType } from "../packages/excalidraw/utility-types";
@ -354,11 +355,15 @@ const ExcalidrawWrapper = () => {
}, [] as FileId[]) || [];
if (data.isExternalScene) {
loadFilesFromFirebase(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
).then(({ loadedFiles, erroredFiles }) => {
getStorageBackend()
.then((storageBackend) => {
return storageBackend.loadFilesFromStorageBackend(
`${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
data.key,
fileIds,
);
})
.then(({ loadedFiles, erroredFiles }) => {
excalidrawAPI.addFiles(loadedFiles);
updateStaleImageStatuses({
excalidrawAPI,

View File

@ -83,6 +83,7 @@ import { atom, useAtom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types";
import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds";
import { getStorageBackend } from "../data/config";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
@ -140,7 +141,12 @@ class Collab extends PureComponent<Props, CollabState> {
throw new AbortError();
}
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
const storageBackend = await getStorageBackend();
return storageBackend.loadFilesFromStorageBackend(
`files/rooms/${roomId}`,
roomKey,
fileIds,
);
},
saveFiles: async ({ addedFiles }) => {
const { roomId, roomKey } = this.portal;
@ -148,7 +154,8 @@ class Collab extends PureComponent<Props, CollabState> {
throw new AbortError();
}
return saveFilesToFirebase({
const storageBackend = await getStorageBackend();
return storageBackend.saveFilesToStorageBackend({
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
files: await encodeFilesForUpload({
files: addedFiles,
@ -267,11 +274,8 @@ class Collab extends PureComponent<Props, CollabState> {
syncableElements: readonly SyncableExcalidrawElement[],
) => {
try {
const savedData = await saveToFirebase(
this.portal,
syncableElements,
this.excalidrawAPI.getAppState(),
);
const storageBackend = await getStorageBackend();
const savedData = await storageBackend.saveToStorageBackend(this.portal, syncableElements, this.excalidrawAPI.getAppState());
if (this.isCollaborating() && savedData && savedData.reconciledElements) {
this.handleRemoteSceneUpdate(
@ -656,11 +660,12 @@ class Collab extends PureComponent<Props, CollabState> {
this.excalidrawAPI.resetScene();
try {
const elements = await loadFromFirebase(
roomLinkData.roomId,
roomLinkData.roomKey,
this.portal.socket,
);
const storageBackend = await getStorageBackend();
const elements = await storageBackend.loadFromStorageBackend(
roomLinkData.roomId,
roomLinkData.roomKey,
this.portal.socket,
);
if (elements) {
this.setLastBroadcastedOrReceivedSceneVersion(
getSceneVersion(elements),

View File

@ -23,7 +23,7 @@ import type { Socket } from "socket.io-client";
class Portal {
collab: TCollabClass;
socket: Socket | null = null;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
@ -33,7 +33,7 @@ class Portal {
this.collab = collab;
}
open(socket: Socket, id: string, key: string) {
open(socket: SocketIOClient.Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;

View File

@ -0,0 +1,45 @@
import { SyncableExcalidrawElement } from ".";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types";
import { AppState, BinaryFileData } from "../../packages/excalidraw/types";
import Portal from "../collab/Portal";
export interface StorageBackend {
isSaved: (portal: Portal, elements: readonly ExcalidrawElement[]) => boolean;
saveToStorageBackend: (
portal: Portal,
elements: readonly SyncableExcalidrawElement[],
appState: AppState,
) => Promise<false | { reconciledElements: any }>;
loadFromStorageBackend: (
roomId: string,
roomKey: string,
socket: SocketIOClient.Socket | null,
) => Promise<readonly ExcalidrawElement[] | null>;
saveFilesToStorageBackend: ({
prefix,
files,
}: {
prefix: string;
files: {
id: FileId;
buffer: Uint8Array;
}[];
}) => Promise<{
savedFiles: Map<FileId, true>;
erroredFiles: Map<FileId, true>;
}>;
loadFilesFromStorageBackend: (
prefix: string,
decryptionKey: string,
filesIds: readonly FileId[],
) => Promise<{
loadedFiles: BinaryFileData[];
erroredFiles: Map<FileId, true>;
}>;
}
export interface StoredScene {
sceneVersion: number;
iv: Uint8Array;
ciphertext: ArrayBuffer;
}

View File

@ -0,0 +1,54 @@
import {
isSavedToFirebase,
loadFilesFromFirebase,
loadFromFirebase,
saveFilesToFirebase,
saveToFirebase,
} from "./firebase";
import {
isSavedToHttpStorage,
loadFilesFromHttpStorage,
loadFromHttpStorage,
saveFilesToHttpStorage,
saveToHttpStorage,
} from "./httpStorage";
import { StorageBackend } from "./StorageBackend";
const firebaseStorage: StorageBackend = {
isSaved: isSavedToFirebase,
saveToStorageBackend: saveToFirebase,
loadFromStorageBackend: loadFromFirebase,
saveFilesToStorageBackend: saveFilesToFirebase,
loadFilesFromStorageBackend: loadFilesFromFirebase,
};
const httpStorage: StorageBackend = {
isSaved: isSavedToHttpStorage,
saveToStorageBackend: saveToHttpStorage,
loadFromStorageBackend: loadFromHttpStorage,
saveFilesToStorageBackend: saveFilesToHttpStorage,
loadFilesFromStorageBackend: loadFilesFromHttpStorage,
};
const storageBackends = new Map<string, StorageBackend>()
.set("firebase", firebaseStorage)
.set("http", httpStorage);
export let storageBackend: StorageBackend | null = null;
export async function getStorageBackend() {
if (storageBackend) {
return storageBackend;
}
const storageBackendName = import.meta.env.VITE_APP_STORAGE_BACKEND || '';
if (storageBackends.has(storageBackendName)) {
storageBackend = storageBackends.get(storageBackendName) as StorageBackend;
} else {
console.warn("No storage backend found, default to firebase");
storageBackend = firebaseStorage;
}
return storageBackend;
}

View File

@ -21,7 +21,7 @@ import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { reconcileElements } from "../collab/reconciliation";
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { ResolutionType } from "../../packages/excalidraw/utility-types";
import type { Socket } from "socket.io-client";
import { Socket } from "socket.io-client";
// private
// -----------------------------------------------------------------------------
@ -49,8 +49,10 @@ const _loadFirebase = async () => {
const firebase = (
await import(/* webpackChunkName: "firebase" */ "firebase/app")
).default;
const storage = import.meta.env.VITE_APP_STORAGE_BACKEND;
const useFirebase = storage === "firebase";
if (!isFirebaseInitialized) {
if (useFirebase && !isFirebaseInitialized) {
try {
firebase.initializeApp(FIREBASE_CONFIG);
} catch (error: any) {
@ -139,12 +141,12 @@ const decryptElements = async (
};
class FirebaseSceneVersionCache {
private static cache = new WeakMap<Socket, number>();
static get = (socket: Socket) => {
private static cache = new WeakMap<SocketIOClient.Socket, number>();
static get = (socket: SocketIOClient.Socket) => {
return FirebaseSceneVersionCache.cache.get(socket);
};
static set = (
socket: Socket,
socket: SocketIOClient.Socket,
elements: readonly SyncableExcalidrawElement[],
) => {
FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
@ -286,7 +288,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async (
roomId: string,
roomKey: string,
socket: Socket | null,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await loadFirestore();
const db = firebase.firestore();

View File

@ -0,0 +1,266 @@
// Inspired and partly copied from https://gitlab.com/kiliandeca/excalidraw-fork
// MIT, Kilian Decaderincourt
import { getSyncableElements, SyncableExcalidrawElement } from ".";
import { MIME_TYPES } from "../../packages/excalidraw/constants";
import { decompressData } from "../../packages/excalidraw/data/encode";
import { encryptData, decryptData, IV_LENGTH_BYTES } from "../../packages/excalidraw/data/encryption";
import { restoreElements } from "../../packages/excalidraw/data/restore";
import { getSceneVersion } from "../../packages/excalidraw/element";
import { ExcalidrawElement, FileId } from "../../packages/excalidraw/element/types";
import {
AppState,
BinaryFileData,
BinaryFileMetadata,
DataURL,
} from "../../packages/excalidraw/types";
import Portal from "../collab/Portal";
import { reconcileElements } from "../collab/reconciliation";
import { StoredScene } from "./StorageBackend";
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const SCENE_VERSION_LENGTH_BYTES = 4
// There is a lot of intentional duplication with the firebase file
// to prevent modifying upstream files and ease futur maintenance of this fork
const httpStorageSceneVersionCache = new WeakMap<
SocketIOClient.Socket,
number
>();
export const isSavedToHttpStorage = (
portal: Portal,
elements: readonly ExcalidrawElement[],
): boolean => {
if (portal.socket && portal.roomId && portal.roomKey) {
const sceneVersion = getSceneVersion(elements);
return httpStorageSceneVersionCache.get(portal.socket) === sceneVersion;
}
// if no room exists, consider the room saved so that we don't unnecessarily
// prevent unload (there's nothing we could do at that point anyway)
return true;
};
export const saveToHttpStorage = async (
portal: Portal,
elements: readonly SyncableExcalidrawElement[],
appState: AppState,
) => {
const { roomId, roomKey, socket } = portal;
if (
// if no room exists, consider the room saved because there's nothing we can
// do at this point
!roomId ||
!roomKey ||
!socket ||
isSavedToHttpStorage(portal, elements)
) {
return false;
}
const sceneVersion = getSceneVersion(elements);
const getResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
);
if (!getResponse.ok && getResponse.status !== 404) {
return false;
};
if(getResponse.status === 404) {
const result: boolean = await saveElementsToBackend(roomKey, roomId, [...elements], sceneVersion)
if(result) {
return {
reconciledElements: null
}
}
return false
};
// If room already exist, we compare scene versions to check
// if we're up to date before saving our scene
const buffer = await getResponse.arrayBuffer();
const sceneVersionFromRequest = parseSceneVersionFromRequest(buffer);
if (sceneVersionFromRequest >= sceneVersion) {
return false;
}
const existingElements = await getElementsFromBuffer(buffer, roomKey);
const reconciledElements = getSyncableElements(
reconcileElements(elements, existingElements, appState),
);
const result: boolean = await saveElementsToBackend(roomKey, roomId, reconciledElements, sceneVersion)
if (result) {
httpStorageSceneVersionCache.set(socket, sceneVersion);
return {
reconciledElements: elements
};
}
return false;
};
export const loadFromHttpStorage = async (
roomId: string,
roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const getResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
);
const buffer = await getResponse.arrayBuffer();
const elements = await getElementsFromBuffer(buffer, roomKey);
if (socket) {
httpStorageSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements, null);
};
const getElementsFromBuffer = async (
buffer: ArrayBuffer,
key: string,
): Promise<readonly ExcalidrawElement[]> => {
// Buffer should contain both the IV (fixed length) and encrypted data
const sceneVersion = parseSceneVersionFromRequest(buffer);
const iv = new Uint8Array(buffer.slice(SCENE_VERSION_LENGTH_BYTES, IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES));
const encrypted = buffer.slice(IV_LENGTH_BYTES + SCENE_VERSION_LENGTH_BYTES, buffer.byteLength);
return await decryptElements(
{ sceneVersion: sceneVersion, ciphertext: encrypted, iv },
key
);
};
export const saveFilesToHttpStorage = async ({
prefix,
files,
}: {
prefix: string;
files: { id: FileId; buffer: Uint8Array }[];
}) => {
const erroredFiles = new Map<FileId, true>();
const savedFiles = new Map<FileId, true>();
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
await Promise.all(
files.map(async ({ id, buffer }) => {
try {
const payloadBlob = new Blob([buffer]);
const payload = await new Response(payloadBlob).arrayBuffer();
await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`, {
method: "PUT",
body: payload,
});
savedFiles.set(id, true);
} catch (error: any) {
erroredFiles.set(id, true);
}
}),
);
return { savedFiles, erroredFiles };
};
export const loadFilesFromHttpStorage = async (
prefix: string,
decryptionKey: string,
filesIds: readonly FileId[],
) => {
const loadedFiles: BinaryFileData[] = [];
const erroredFiles = new Map<FileId, true>();
//////////////
await Promise.all(
[...new Set(filesIds)].map(async (id) => {
try {
const HTTP_STORAGE_BACKEND_URL = import.meta.env.VITE_APP_HTTP_STORAGE_BACKEND_URL;
const response = await fetch(`${HTTP_STORAGE_BACKEND_URL}/files/${id}`);
if (response.status < 400) {
const arrayBuffer = await response.arrayBuffer();
const { data, metadata } = await decompressData<BinaryFileMetadata>(
new Uint8Array(arrayBuffer),
{
decryptionKey,
},
);
const dataURL = new TextDecoder().decode(data) as DataURL;
loadedFiles.push({
mimeType: metadata.mimeType || MIME_TYPES.binary,
id,
dataURL,
created: metadata?.created || Date.now(),
});
} else {
erroredFiles.set(id, true);
}
} catch (error: any) {
erroredFiles.set(id, true);
console.error(error);
}
}),
);
//////
return { loadedFiles, erroredFiles };
};
const saveElementsToBackend = async (roomKey: string, roomId: string, elements: SyncableExcalidrawElement[], sceneVersion: number) => {
const { ciphertext, iv } = await encryptElements(roomKey, elements);
// Concatenate Scene Version, IV with encrypted data (IV does not have to be secret).
const numberBuffer = new ArrayBuffer(4);
const numberView = new DataView(numberBuffer);
numberView.setUint32(0, sceneVersion, false);
const sceneVersionBuffer = numberView.buffer;
const payloadBlob = await new Response(new Blob([sceneVersionBuffer, iv.buffer, ciphertext])).arrayBuffer();
const putResponse = await fetch(
`${HTTP_STORAGE_BACKEND_URL}/rooms/${roomId}`,
{
method: "PUT",
body: payloadBlob,
},
);
return putResponse.ok
}
const parseSceneVersionFromRequest = (buffer: ArrayBuffer) => {
const view = new DataView(buffer);
return view.getUint32(0, false);
}
const decryptElements = async (
data: StoredScene,
roomKey: string,
): Promise<readonly ExcalidrawElement[]> => {
const ciphertext = data.ciphertext;
const iv = data.iv;
const decrypted = await decryptData(iv, ciphertext, roomKey);
const decodedData = new TextDecoder("utf-8").decode(
new Uint8Array(decrypted),
);
return JSON.parse(decodedData);
};
const encryptElements = async (
key: string,
elements: readonly ExcalidrawElement[],
): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
const json = JSON.stringify(elements);
const encoded = new TextEncoder().encode(json);
const { encryptedBuffer, iv } = await encryptData(key, encoded);
return { ciphertext: encryptedBuffer, iv };
};

View File

@ -4,6 +4,7 @@ import {
} from "../../packages/excalidraw/data/encode";
import {
decryptData,
encryptData,
generateEncryptionKey,
IV_LENGTH_BYTES,
} from "../../packages/excalidraw/data/encryption";
@ -33,7 +34,7 @@ import {
WS_SUBTYPES,
} from "../app_constants";
import { encodeFilesForUpload } from "./FileManager";
import { saveFilesToFirebase } from "./firebase";
import { getStorageBackend } from "./config";
export type SyncableExcalidrawElement = ExcalidrawElement & {
_brand: "SyncableExcalidrawElement";
@ -343,7 +344,8 @@ export const exportToBackend = async (
url.hash = `json=${json.id},${encryptionKey}`;
const urlString = url.toString();
await saveFilesToFirebase({
const storageBackend = await getStorageBackend();
await storageBackend.saveFilesToStorageBackend({
prefix: `/files/shareLinks/${json.id}`,
files: filesToUpload,
});

View File

@ -30,7 +30,7 @@
"private": true,
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true VITE_APP_ENABLE_ESLINT=false vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version",

View File

@ -35,6 +35,9 @@ interface ImportMetaEnv {
VITE_APP_GIT_SHA: string;
VITE_APP_HTTP_STORAGE_BACKEND_URL: string;
VITE_APP_STORAGE_BACKEND: "http" | "firebase";
MODE: string;
DEV: string;

View File

@ -10,9 +10,8 @@ const envVars = loadEnv("", `../`);
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: Number(envVars.VITE_APP_PORT || 3000),
// open the browser
open: true,
host: '0.0.0.0',
port: Number(envVars.VITE_APP_PORT || 3000)
},
// We need to specify the envDir since now there are no
//more located in parallel with the vite.config.ts file but in parent dir

View File

@ -17,6 +17,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@excalidraw/eslint-config": "1.0.3",
@ -58,9 +59,9 @@
"prettier": "@excalidraw/prettier-config",
"scripts": {
"build-node": "node ./scripts/build-node.js",
"build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true VITE_APP_DISABLE_TRACKING=true vite build",
"build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA vite build",
"build:version": "node ./scripts/build-version.js",
"build:app:docker": "yarn --cwd ./excalidraw-app build:app:docker",
"build:app": "yarn --cwd ./excalidraw-app build:app",
"build:version": "yarn --cwd ./excalidraw-app build:version",
"build": "yarn --cwd ./excalidraw-app build",
"fix:code": "yarn test:code --fix",
"fix:other": "yarn prettier --write",