removed firebase and added support for rooms without firebase and fix docker setup
This commit is contained in:
parent
d19b51d4f8
commit
ad571b12b8
@ -4,6 +4,7 @@
|
||||
!.eslintrc.json
|
||||
!.npmrc
|
||||
!.prettierrc
|
||||
!excalidraw-app/
|
||||
!package.json
|
||||
!public/
|
||||
!packages/
|
||||
|
1
.env.default
Normal file
1
.env.default
Normal file
@ -0,0 +1 @@
|
||||
REDIS_PASSWORD=CHANGE_ME
|
@ -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
|
||||
|
@ -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
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
.env.development.local
|
||||
.env.local
|
||||
.env
|
||||
.env.production.local
|
||||
.env.test.local
|
||||
.envrc
|
||||
|
17
Dockerfile
17
Dockerfile
@ -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
|
79
README.md
79
README.md
@ -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
44
docker-compose-prod.yml
Normal 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:
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
|
45
excalidraw-app/data/StorageBackend.ts
Normal file
45
excalidraw-app/data/StorageBackend.ts
Normal 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;
|
||||
}
|
54
excalidraw-app/data/config.ts
Normal file
54
excalidraw-app/data/config.ts
Normal 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;
|
||||
}
|
@ -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();
|
||||
|
266
excalidraw-app/data/httpStorage.ts
Normal file
266
excalidraw-app/data/httpStorage.ts
Normal 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 };
|
||||
};
|
@ -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,
|
||||
});
|
||||
|
@ -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",
|
||||
|
3
excalidraw-app/vite-env.d.ts
vendored
3
excalidraw-app/vite-env.d.ts
vendored
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user