├── .dockerignore ├── .prettierrc ├── nest-cli.json ├── tsconfig.build.json ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── src ├── raw-parser.middleware.spec.ts ├── storage │ ├── storage.service.spec.ts │ └── storage.service.ts ├── files │ ├── files.controller.spec.ts │ └── files.controller.ts ├── rooms │ ├── rooms.controller.spec.ts │ └── rooms.controller.ts ├── raw-parser.middleware.ts ├── scenes │ ├── scenes.controller.spec.ts │ └── scenes.controller.ts ├── main.ts └── app.module.ts ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ ├── publish-docker.yml │ ├── publish-docker-tag.yml │ └── ci.yaml ├── .eslintrc.js ├── .gitlab-ci.yml ├── docker-compose.yml ├── LICENSE ├── Dockerfile ├── README.md └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/raw-parser.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import { RawParserMiddleware } from './raw-parser.middleware'; 2 | 3 | describe('RawParserMiddleware', () => { 4 | it('should be defined', () => { 5 | expect(new RawParserMiddleware()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /src/storage/storage.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { StorageService } from './storage.service'; 3 | 4 | describe('StorageService', () => { 5 | let service: StorageService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [StorageService], 10 | }).compile(); 11 | 12 | service = module.get(StorageService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/files/files.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FilesController } from './files.controller'; 3 | 4 | describe('FilesController', () => { 5 | let controller: FilesController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [FilesController], 10 | }).compile(); 11 | 12 | controller = module.get(FilesController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/rooms/rooms.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { RoomsController } from './rooms.controller'; 3 | 4 | describe('RoomsController', () => { 5 | let controller: RoomsController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [RoomsController], 10 | }).compile(); 11 | 12 | controller = module.get(RoomsController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/raw-parser.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { raw } from 'express'; 3 | import { hasBody } from 'type-is'; 4 | 5 | // Excalidraw Front end doesn't send a Content Type Header 6 | // so we tell raw parser to check if there is a body 7 | const rawParserMiddleware = raw({ 8 | type: hasBody, 9 | limit: process.env.BODY_LIMIT ?? '50mb', 10 | }); 11 | 12 | @Injectable() 13 | export class RawParserMiddleware implements NestMiddleware { 14 | use(req: any, res: any, next: () => void) { 15 | rawParserMiddleware(req, res, next); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/scenes/scenes.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ScenesController } from './scenes.controller'; 3 | 4 | describe('ScenesController', () => { 5 | let controller: ScenesController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [ScenesController], 10 | }).compile(); 11 | 12 | controller = module.get(ScenesController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | 5 | function isLogLevel(value: any): value is LogLevel { 6 | return value in ['log', 'error', 'warn', 'debug', 'verbose']; 7 | } 8 | 9 | async function bootstrap() { 10 | const logLevel = isLogLevel(process.env.LOG_LEVEL) 11 | ? process.env.LOG_LEVEL 12 | : 'log'; 13 | 14 | const app = await NestFactory.create(AppModule, { 15 | cors: true, 16 | logger: [logLevel], 17 | }); 18 | 19 | app.setGlobalPrefix(process.env.GLOBAL_PREFIX ?? '/api/v2'); 20 | 21 | await app.listen(process.env.PORT ?? 8080); 22 | } 23 | bootstrap(); 24 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module } from '@nestjs/common'; 2 | import { RawParserMiddleware } from './raw-parser.middleware'; 3 | import { ScenesController } from './scenes/scenes.controller'; 4 | import { StorageService } from './storage/storage.service'; 5 | import { RoomsController } from './rooms/rooms.controller'; 6 | import { FilesController } from './files/files.controller'; 7 | 8 | @Module({ 9 | imports: [], 10 | controllers: [ScenesController, RoomsController, FilesController], 11 | providers: [StorageService], 12 | }) 13 | export class AppModule { 14 | configure(consumer: MiddlewareConsumer) { 15 | consumer.apply(RawParserMiddleware).forRoutes('**'); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Fork 2 | 3 | on: 4 | push: 5 | branches: 6 | - release-now 7 | 8 | jobs: 9 | publish-docker: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | - name: Login to DockerHub 16 | uses: docker/login-action@v2 17 | with: 18 | username: ${{ secrets.DOCKER_USERNAME }} 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | - name: Build and push 21 | uses: docker/build-push-action@v3 22 | with: 23 | context: . 24 | push: true 25 | tags: alswl/excalidraw-storage-backend:latest 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker-tag.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish-docker: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | - name: Login to DockerHub 16 | uses: docker/login-action@v2 17 | with: 18 | username: ${{ secrets.DOCKER_USERNAME }} 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | - name: Build and push 21 | uses: docker/build-push-action@v3 22 | with: 23 | context: . 24 | push: true 25 | tags: alswl/excalidraw-storage-backend:${{ github.ref_name }} 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm install -g npm@latest 24 | - run: npm install -g eslint 25 | - run: npm install -g @nestjs/cli 26 | - run: npm install 27 | - run: npm ci --prod 28 | - run: npx nest build 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: "to-be-continuous/docker" 3 | ref: "2.0.0" 4 | file: "/templates/gitlab-ci-docker.yml" 5 | 6 | variables: 7 | PROD_REF: "/^main$/" 8 | AUTODEPLOY_TO_PROD: "true" # Always publish 9 | 10 | DOCKER_REGISTRY_RELEASE_USER: kiliandeca 11 | # DOCKER_REGISTRY_RELEASE_PASSWORD: # Defined in CI/CD Settings 12 | DOCKER_RELEASE_IMAGE: docker.io/kiliandeca/excalidraw-storage-backend:latest 13 | 14 | stages: 15 | - build 16 | - package-build 17 | - package-test 18 | - publish 19 | 20 | .node-template: 21 | image: node:14-alpine 22 | before_script: 23 | - npm ci --cache .npm --prefer-offline 24 | cache: 25 | key: npm-cache 26 | paths: 27 | - .npm-cache 28 | 29 | node-build: 30 | stage: build 31 | extends: .node-template 32 | script: 33 | - npm run build 34 | artifacts: 35 | paths: 36 | - dist 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | excalidraw: 5 | image: kiliandeca/excalidraw 6 | healthcheck: 7 | disable: true 8 | ports: 9 | - "80:80" 10 | environment: 11 | BACKEND_V2_GET_URL: http://localhost:8080/api/v2/scenes/ 12 | BACKEND_V2_POST_URL: http://localhost:8080/api/v2/scenes/ 13 | LIBRARY_URL: https://libraries.excalidraw.com 14 | LIBRARY_BACKEND: https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries 15 | SOCKET_SERVER_URL: http://localhost:5000/ 16 | STORAGE_BACKEND: "http" 17 | HTTP_STORAGE_BACKEND_URL: "http://localhost:8080/api/v2" 18 | 19 | excalidraw-storage-backend: 20 | build: . 21 | ports: 22 | - "8080:8080" 23 | environment: 24 | STORAGE_URI: redis://redis:6379 25 | 26 | excalidraw-room: 27 | image: excalidraw/excalidraw-room 28 | ports: 29 | - "5000:80" 30 | 31 | redis: 32 | image: redis 33 | ports: 34 | - "6379:6379" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kilian Decaderincourt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as builder 2 | 3 | ARG CHINA_MIRROR=false 4 | 5 | # enable china mirror when ENABLE_CHINA_MIRROR is true 6 | RUN if [[ "$CHINA_MIRROR" = "true" ]] ; then \ 7 | echo "Enable China Alpine Mirror" && \ 8 | sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories; \ 9 | fi 10 | 11 | RUN if [[ "$CHINA_MIRROR" = "true" ]] ; then \ 12 | echo "Enable China NPM Mirror" && \ 13 | npm install -g cnpm --registry=https://registry.npmmirror.com; \ 14 | npm config set registry https://registry.npmmirror.com; \ 15 | fi 16 | 17 | RUN apk add --update python3 make g++ curl 18 | RUN npm install -g eslint 19 | RUN npm install -g @nestjs/cli 20 | 21 | WORKDIR /app 22 | 23 | COPY package.json . 24 | COPY package-lock.json . 25 | RUN npm install 26 | 27 | COPY . . 28 | RUN npm ci --prod 29 | RUN npx nest build 30 | 31 | 32 | FROM node:20-alpine 33 | 34 | WORKDIR /app 35 | 36 | COPY --from=builder /app/package.json /app/package.json 37 | COPY --from=builder /app/dist /app/dist 38 | COPY --from=builder /app/node_modules /app/node_modules 39 | 40 | USER node 41 | 42 | EXPOSE 8080 43 | 44 | ENTRYPOINT ["npm", "run", "start:prod"] 45 | -------------------------------------------------------------------------------- /src/rooms/rooms.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Header, 6 | Logger, 7 | NotFoundException, 8 | Param, 9 | Put, 10 | Res, 11 | } from '@nestjs/common'; 12 | import { Response } from 'express'; 13 | import { StorageNamespace, StorageService } from 'src/storage/storage.service'; 14 | import { Readable } from 'stream'; 15 | 16 | @Controller('rooms') 17 | export class RoomsController { 18 | private readonly logger = new Logger(RoomsController.name); 19 | namespace = StorageNamespace.ROOMS; 20 | 21 | constructor(private storageService: StorageService) {} 22 | 23 | @Get(':id') 24 | @Header('content-type', 'application/octet-stream') 25 | async findOne(@Param() params, @Res() res: Response): Promise { 26 | const data = await this.storageService.get(params.id, this.namespace); 27 | this.logger.debug(`Get room ${params.id}`); 28 | 29 | if (!data) { 30 | throw new NotFoundException(); 31 | } 32 | 33 | const stream = new Readable(); 34 | stream.push(data); 35 | stream.push(null); 36 | stream.pipe(res); 37 | } 38 | 39 | @Put(':id') 40 | async create(@Param() params, @Body() payload: Buffer) { 41 | const id = params.id; 42 | await this.storageService.set(id, payload, this.namespace); 43 | this.logger.debug(`Created room ${id}`); 44 | 45 | return { 46 | id, 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/files/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Header, 6 | Logger, 7 | NotFoundException, 8 | Param, 9 | Put, 10 | Res, 11 | } from '@nestjs/common'; 12 | import { Response } from 'express'; 13 | import { StorageNamespace, StorageService } from 'src/storage/storage.service'; 14 | import { Readable } from 'stream'; 15 | 16 | @Controller('files') 17 | export class FilesController { 18 | private readonly logger = new Logger(FilesController.name); 19 | namespace = StorageNamespace.FILES; 20 | 21 | constructor(private storageService: StorageService) {} 22 | 23 | @Get(':id') 24 | @Header('content-type', 'application/octet-stream') 25 | async findOne(@Param() params, @Res() res: Response): Promise { 26 | const data = await this.storageService.get(params.id, this.namespace); 27 | this.logger.debug(`Get image ${params.id}`); 28 | 29 | if (!data) { 30 | throw new NotFoundException(); 31 | } 32 | 33 | const stream = new Readable(); 34 | stream.push(data); 35 | stream.push(null); 36 | stream.pipe(res); 37 | } 38 | 39 | @Put(':id') 40 | async create(@Param() params, @Body() payload: Buffer) { 41 | const id = params.id; 42 | await this.storageService.set(id, payload, this.namespace); 43 | this.logger.debug(`Created image ${id}`); 44 | 45 | return { 46 | id, 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/storage/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import * as Keyv from 'keyv'; 3 | 4 | @Injectable() 5 | export class StorageService { 6 | private readonly logger = new Logger(StorageService.name); 7 | storagesMap = new Map(); 8 | 9 | constructor() { 10 | const uri = process.env[`STORAGE_URI`]; 11 | if (!uri) { 12 | this.logger.warn( 13 | `STORAGE_URI is undefined, will use non persistant in memory storage`, 14 | ); 15 | } 16 | 17 | Object.keys(StorageNamespace).forEach((namespace) => { 18 | const keyv = new Keyv({ 19 | uri, 20 | namespace, 21 | }); 22 | keyv.on('error', (err) => 23 | this.logger.error(`Connection Error for namespace ${namespace}`, err), 24 | ); 25 | this.storagesMap.set(namespace, keyv); 26 | }); 27 | } 28 | get(key: string, namespace: StorageNamespace): Promise { 29 | return this.storagesMap.get(namespace).get(key); 30 | } 31 | async has(key: string, namespace: StorageNamespace): Promise { 32 | return !!(await this.storagesMap.get(namespace).get(key)); 33 | } 34 | set(key: string, value: Buffer, namespace: StorageNamespace): Promise { 35 | return this.storagesMap.get(namespace).set(key, value); 36 | } 37 | } 38 | 39 | export enum StorageNamespace { 40 | SCENES = 'SCENES', 41 | ROOMS = 'ROOMS', 42 | FILES = 'FILES', 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # excalidraw-storage-backend 2 | 3 | This is a reimplementation of [excalidraw-json](https://github.com/excalidraw/excalidraw-json) suitable for self hosting you own instance of Excalidraw. 4 | 5 | It can be used with [kiliandeca/excalidraw-fork](https://gitlab.com/kiliandeca/excalidraw-fork) 6 | 7 | [DockerHub kiliandeca/excalidraw-storage-backend](https://hub.docker.com/r/kiliandeca/excalidraw-storage-backend) 8 | 9 | Feature: 10 | 11 | - Storing scenes: when you export as a link 12 | - Storing rooms: when you create a live collaboration 13 | - Storing images: when you export or do a live collaboration of a scene with images 14 | 15 | It use Keyv as a simple K/V store so you can use the database of your choice. 16 | 17 | ## Environement Variables 18 | 19 | | Name | Description | Default value | 20 | | --------------- | ------------------------------------------------------------ | ---------------- | 21 | | `PORT` | Server listening port | 8080 | 22 | | `GLOBAL_PREFIX` | API global prefix for every routes | `/api/v2` | 23 | | `STORAGE_URI` | [Keyv](https://github.com/jaredwray/keyv) connection string, example: `redis://user:pass@localhost:6379`. Availabe Keyv storage adapter: redis, mongo, postgres and mysql | `""` (in memory **non-persistent**) | 24 | | `LOG_LEVEL` | Log level (`debug`, `verbose`, `log`, `warn`, `error`) | `warn` | 25 | | `BODY_LIMIT` | Payload size limit for scenes or images | `50mb` | 26 | -------------------------------------------------------------------------------- /src/scenes/scenes.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Header, 6 | InternalServerErrorException, 7 | Logger, 8 | NotFoundException, 9 | Param, 10 | Post, 11 | Res, 12 | } from '@nestjs/common'; 13 | import { Response } from 'express'; 14 | import { StorageNamespace, StorageService } from 'src/storage/storage.service'; 15 | import { Readable } from 'stream'; 16 | import { customAlphabet } from 'nanoid'; 17 | 18 | @Controller('scenes') 19 | export class ScenesController { 20 | private readonly logger = new Logger(ScenesController.name); 21 | namespace = StorageNamespace.SCENES; 22 | 23 | constructor(private storageService: StorageService) {} 24 | @Get(':id') 25 | @Header('content-type', 'application/octet-stream') 26 | async findOne(@Param() params, @Res() res: Response): Promise { 27 | const data = await this.storageService.get(params.id, this.namespace); 28 | this.logger.debug(`Get scene ${params.id}`); 29 | 30 | if (!data) { 31 | throw new NotFoundException(); 32 | } 33 | 34 | const stream = new Readable(); 35 | stream.push(data); 36 | stream.push(null); 37 | stream.pipe(res); 38 | } 39 | 40 | @Post() 41 | async create(@Body() payload: Buffer) { 42 | // Excalidraw front-end only support numeric id, we can't use nanoid default alphabet 43 | const nanoid = customAlphabet('0123456789', 16); 44 | const id = nanoid(); 45 | 46 | // Check for collision 47 | if (await this.storageService.get(id, this.namespace)) { 48 | throw new InternalServerErrorException(); 49 | } 50 | 51 | await this.storageService.set(id, payload, this.namespace); 52 | this.logger.debug(`Created scene ${id}`); 53 | 54 | return { 55 | id, 56 | }; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excalidraw-storage-backend", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@keyv/mongo": "^1.2.1", 25 | "@keyv/mysql": "^1.1.5", 26 | "@keyv/postgres": "^1.0.17", 27 | "@keyv/redis": "^2.2.0", 28 | "@keyv/sqlite": "^2.0.3", 29 | "sqlite3": "5.0.3", 30 | "@nestjs/common": "^8.0.0", 31 | "@nestjs/core": "^8.0.0", 32 | "@nestjs/platform-express": "^8.0.0", 33 | "@types/keyv": "^3.1.3", 34 | "keyv": "^4.0.4", 35 | "nanoid": "^3.1.25", 36 | "reflect-metadata": "^0.1.13", 37 | "rimraf": "^3.0.2", 38 | "rxjs": "^7.2.0" 39 | }, 40 | "devDependencies": { 41 | "@nestjs/cli": "^8.0.0", 42 | "@nestjs/schematics": "^8.0.0", 43 | "@nestjs/testing": "^8.0.0", 44 | "@types/express": "^4.17.13", 45 | "@types/jest": "^27.0.1", 46 | "@types/node": "^16.0.0", 47 | "@types/supertest": "^2.0.11", 48 | "@types/type-is": "^1.6.3", 49 | "@typescript-eslint/eslint-plugin": "^4.28.2", 50 | "@typescript-eslint/parser": "^4.28.2", 51 | "eslint": "^7.30.0", 52 | "eslint-config-prettier": "^8.3.0", 53 | "eslint-plugin-prettier": "^3.4.0", 54 | "jest": "^27.0.6", 55 | "prettier": "^2.3.2", 56 | "supertest": "^6.1.3", 57 | "ts-jest": "^27.0.3", 58 | "ts-loader": "^9.2.3", 59 | "ts-node": "^10.0.0", 60 | "tsconfig-paths": "^3.10.1", 61 | "typescript": "^4.3.5" 62 | }, 63 | "jest": { 64 | "moduleFileExtensions": [ 65 | "js", 66 | "json", 67 | "ts" 68 | ], 69 | "rootDir": "src", 70 | "testRegex": ".*\\.spec\\.ts$", 71 | "transform": { 72 | "^.+\\.(t|j)s$": "ts-jest" 73 | }, 74 | "collectCoverageFrom": [ 75 | "**/*.(t|j)s" 76 | ], 77 | "coverageDirectory": "../coverage", 78 | "testEnvironment": "node" 79 | } 80 | } 81 | --------------------------------------------------------------------------------