├── .dockerignore ├── .editorconfig ├── .github └── workflows │ ├── build-docker.yml │ └── linting-testing-code.yml ├── .gitignore ├── .prettierrc.json ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── favicon.ico └── images │ ├── bg_dots.png │ ├── bg_grid.png │ ├── bg_white.png │ ├── dottedRec.png │ └── slider-background.svg ├── config.default.yml ├── config ├── webpack.base.js ├── webpack.build.js └── webpack.dev.js ├── doc ├── iconPrev.jpg ├── nextcloud_icons │ ├── whiteboard-dark.png │ └── whiteboard.png └── start.png ├── docker-compose.yml ├── package-lock.json ├── package.json ├── scripts ├── config │ ├── config-schema.json │ ├── config.js │ ├── utils.js │ └── utils.test.js ├── s_whiteboard.js ├── server-backend.js ├── server-frontend-dev.js ├── server.js ├── services │ ├── ReadOnlyBackendService.js │ ├── WhiteboardInfoBackendService.js │ └── WhiteboardInfoBackendService.test.js └── utils.js └── src ├── css └── main.css ├── index.html └── js ├── classes └── Point.js ├── icons.js ├── index.js ├── keybinds.js ├── main.js ├── services ├── ConfigService.js ├── ConfigService.utils.js ├── ConfigService.utils.test.js ├── InfoService.js ├── ReadOnlyService.js └── ThrottlingService.js ├── shortcutFunctions.js ├── utils.js └── whiteboard.js /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/git 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [{*.js,*.css,*.html}] 12 | indent_size = 4 13 | -------------------------------------------------------------------------------- /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do clean build of the docker image 2 | 3 | name: Docker Image CI (also tests build) 4 | 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: build the image 22 | run: | 23 | docker buildx build --no-cache \ 24 | --file Dockerfile \ 25 | --tag rofl256/whiteboard:$(date +%s) \ 26 | --platform linux/amd64,linux/arm64 . 27 | -------------------------------------------------------------------------------- /.github/workflows/linting-testing-code.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies and check the code style 2 | 3 | name: Linting and testing code CI 4 | 5 | on: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | branches: [master] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js 18.x 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 18.x 20 | - run: npm install 21 | - run: npm run style 22 | - run: npm run test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config.run.yml 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Compilation result 17 | /dist 18 | 19 | # upload folder, etc. 20 | /public/uploads 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (http://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Typescript v1 declaration files 48 | typings/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | 68 | /public/apidoc 69 | /savedBoards/* 70 | 71 | # Temp swap file 72 | *.swp 73 | 74 | # pnpm 75 | pnpm-lock.yaml 76 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS base 2 | 3 | # Create app directory 4 | RUN mkdir -p /opt/app 5 | WORKDIR /opt/app 6 | 7 | # Install app dependencies 8 | COPY ./package.json package-lock.json ./ 9 | RUN npm install 10 | 11 | # Bundle frontend 12 | COPY src ./src 13 | COPY assets ./assets 14 | COPY config ./config 15 | RUN npm run build 16 | 17 | ##################### 18 | # Final image 19 | ##################### 20 | 21 | FROM node:18-alpine 22 | ENV NODE_ENV=prod 23 | 24 | LABEL maintainer="cracker0dks" 25 | 26 | # Create app directory 27 | RUN mkdir -p /opt/app 28 | WORKDIR /opt/app 29 | 30 | COPY ./package.json ./package-lock.json config.default.yml ./ 31 | RUN npm install --only=prod 32 | 33 | COPY scripts ./scripts 34 | COPY --from=base /opt/app/dist ./dist 35 | 36 | EXPOSE 8080 37 | ENTRYPOINT ["npm", "run", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Cracker 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # whiteboard 2 | 3 | This is a lightweight NodeJS collaborative Whiteboard/Sketchboard which can easily be customized... 4 | 5 | ![start](./doc/start.png) 6 | 7 | ## Demowhiteboard 8 | 9 | [HERE](https://cloud13.de/testwhiteboard/) (Reset every night) 10 | 11 | ## Some Features 12 | 13 | - Shows remote user cursors while drawing 14 | - Undo / Redo function for each user 15 | - Drag+Drop / Copy+Paste Images or PDFs from PC and Browsers 16 | - Resize, Move, Rotate & Draw Images to Canvas or Background 17 | - Write text and sticky notes 18 | - Save Whiteboard to Image and JSON 19 | - Draw angle lines by pressing "Shift" while drawing (with line tool) 20 | - Draw square by pressing "Shift" while drawing (with rectangle tool) 21 | - Indicator that shows the smallest screen participating 22 | - Keybindings for ALL the functions 23 | - REST API 24 | - Working on PC, Tablet & Mobile 25 | 26 | ## Projects using this Whiteboard 27 | 28 | - [Meetzi](https://meetzi.de/) - WebRtc Conference tool 29 | - [LAMS](https://www.lamsfoundation.org) - Managing and delivering online Collaboration learning activities 30 | - [Accelerator](https://github.com/cracker0dks/Accelerator) - WebRtc Conference tool 31 | - Your Project here... 32 | 33 | ## Install the App 34 | 35 | You can run this app with and without docker 36 | 37 | ### Without Docker 38 | 39 | 1. install the latest NodeJs (version >= 12) 40 | 2. Clone the app 41 | 3. Run `npm install` inside the folder 42 | 4. Run `npm run start:prod` 43 | 5. Surf to http://YOURIP:8080 44 | 45 | ### With Docker 46 | 47 | 1. `docker run -d -p 8080:8080 rofl256/whiteboard` 48 | 2. Surf to http://YOURIP:8080 49 | 50 | ## Development 51 | 52 | After you have installed the app, run `npm run start:dev` to start the backend and a frontend development server. The website will be accessible on http://localhost:8080. 53 | 54 | ## Default keyboard shortcuts 55 | 56 | Use keyboard shortcuts to become more productive while using Whiteboard. 57 | 58 | They are especially useful if you work with interactive displays such as XP-Pen Artist, Huion Kamvas and Wacom Cintiq. These devices have quick buttons (6-8 buttons and scrolling). By default, the buttons on these displays are mapped to standard Photoshop keyboard shortcuts. Keys can be configured to function effectively in other software. 59 | 60 | The following are predefined shortcuts that you can override in the file [./src/js/keybinds.js](./src/js/keybinds.js) 61 | 62 | | Result | Windows and Linux | macOS | 63 | | ---------------------------------------------------------------- | -------------------- | ----------------------- | 64 | | Clear the whiteboard | Ctrl + Shift + Del | Command + Shift + Del | 65 | | Undo your last step | Ctrl + Z | Command + Z | 66 | | Redo your last undo | Ctrl + Y | Command + Y | 67 | | Select an area | Ctrl + X | Command + X | 68 | | Take the mouse | Ctrl + M | Command + M | 69 | | Take the pen | Ctrl + P | Command + P | 70 | | Take the pan tool (hand) | Ctrl + Space | Command + Space | 71 | | Draw a line | Ctrl + L | Command + L | 72 | | Draw a rectangle | Ctrl + R | Command + R | 73 | | Draw a circle | Ctrl + C | Command + C | 74 | | Toggle between line, rectangle and circle | Ctrl + Shift + F | Command + Shift + F | 75 | | Toggle between pen and eraser | Ctrl + Shift + X | Command + Shift + X | 76 | | Toggle between main clolors (black, blue, green, yellow and red) | Ctrl + Shift + R | Command + Shift + R | 77 | | Write text | Ctrl + A | Command + A | 78 | | Take the eraser | Ctrl + E | Command + E | 79 | | Increase thickness | Ctrl + Up Arrow | Command + Up Arrow | 80 | | Decrease thickness | Ctrl + Down Arrow | Command + Down Arrow | 81 | | Colorpicker | Ctrl + Shift + C | Command + Shift + C | 82 | | Set black color | Ctrl + Shift + 1 | Command + Shift + 1 | 83 | | Set blue color | Ctrl + Shift + 2 | Command + Shift + 2 | 84 | | Set green color | Ctrl + Shift + 3 | Command + Shift + 3 | 85 | | Set yellow color | Ctrl + Shift + 4 | Command + Shift + 4 | 86 | | Set red color | Ctrl + Shift + 5 | Command + Shift + 5 | 87 | | Save whiteboard as image | Ctrl + S | Command + S | 88 | | Save whiteboard as JSON | Ctrl + Shift + K | Command + Shift + K | 89 | | Save whiteboard to WebDav | Ctrl + Shift + I (i) | Command + Shift + I (i) | 90 | | Load saved JSON to whiteboard | Ctrl + Shift + J | Command + Shift + J | 91 | | Share whiteboard | Ctrl + Shift + S | Command + Shift + S | 92 | | Hide or show toolbar | Tab | Tab | 93 | | Move selected object up | Up Arrow | Up Arrow | 94 | | Move selected object down | Down Arrow | Down Arrow | 95 | | Move selected object left | Left Arrow | Left Arrow | 96 | | Move selected object right | Right Arrow | Right Arrow | 97 | | Drop object | Ctrl + Enter | Command + Enter | 98 | | Add Image to background | Shift + Enter | Shift + Enter | 99 | | Cancel all actions | Escape | Escape | 100 | | Delete selected object | Delete | Delete | 101 | | Use Line tool when pen is active (Not changeable) | Shift (Hold) | Shift (Hold) | 102 | 103 | ## URL Parameters 104 | 105 | Call your site with GET parameters to change the WhiteboardID or the Username 106 | 107 | `http://YOURIP:8080?whiteboardid=MYID&username=MYNAME` 108 | 109 | - whiteboardid => All people with the same ID are drawing on the same board 110 | - username => The name which will be shown to others while drawing 111 | - title => Change the name of the Browser Tab 112 | - randomid => if set to true, a random whiteboardId will be generated if not given aswell 113 | - copyfromwid => set this to a whiteboardId you want a copy from. Only copies the content if the current whiteboard is empty. 114 | 115 | ## Configuration 116 | 117 | Many settings of this project can be set using a simple `yaml` file, to change some behaviors or tweak performances. 118 | 119 | ### Config. file 120 | 121 | To run the project with custom settings: 122 | 123 | 1. Create a `config.run.yml` file based on the content of [`config.default.yml`](./config.default.yml), 124 | 2. Change the settings, 125 | 3. Run the project with your custom configuration (it will be merged into the default one): 126 | 127 | - locally: `node scripts/server.js --config=./config.run.yml` 128 | - docker: `docker run -d -p 8080:8080 -v $(pwd)/config.run.yml:/config.run.yml:ro rofl256/whiteboard --config=/config.run.yml` 129 | 130 | ### Highlights 131 | 132 | #### Security - AccessToken (Optional) 133 | 134 | To prevent clients who might know or guess the base URL from abusing the server to upload files and stuff, you can set an accesstoken at server start (see [here](./config.default.yml)). 135 | 136 | Then set the same token on the client side as well: 137 | 138 | Client (With and without docker): `http://YOURIP:8080?accesstoken=mySecToken&whiteboardid=MYID&username=MYNAME` 139 | 140 | Done! 141 | 142 | #### REST API 143 | 144 | You can fully control the whiteboard through a REST API. Explore and test the API for your server version by surfing to: `[yourRootWhiteboardUrl]/apidoc/index.html` 145 | You can see the API for the Demowhiteboard here: [DemoAPI](https://cloud13.de/testwhiteboard/apidoc/index.html) 146 | 147 | Note: This API is pretty new, so be sure to use the latest Whiteboard version. 148 | 149 | #### WebDAV (Optional) 150 | 151 | This function allows your users to save the whiteboard directly to a webdav server (Nextcloud) as image without downloading it. 152 | 153 | To enable set `enableWebdav` to `true` in the [configuration](./config.default.yml). 154 | 155 | Then set the same parameter on the client side as well: 156 | 157 | Client (With and without docker): `http://YOURIP:8080?webdav=true&whiteboardid=MYID&username=MYNAME` 158 | 159 | Refresh the site and You will notice an extra save button in the top panel. Set your WebDav Parameters, and you are good to go! 160 | 161 | Note: For the most owncloud/nextcloud setups you have to set the WebDav-Server URL to: https://YourDomain.tl/remote.php/webdav/ 162 | 163 | Done! 164 | 165 | ### And many more (performance, etc.) 166 | 167 | Many more settings can be tweaked. All of them are described in the [default config file](./config.default.yml). 168 | 169 | ## Things you may want to know 170 | 171 | - Whiteboards are gone if you restart the Server enable "enableFileDatabase" in the config file or export the board to prevent that. 172 | - You should be able to customize the layout without ever touching the whiteboard.js (take a look at index.html & main.js) 173 | 174 | ## Nginx Reverse Proxy configuration 175 | 176 | Add this to your server part: 177 | 178 | ``` 179 | location /whiteboard/ { 180 | proxy_set_header HOST $host; 181 | proxy_http_version 1.1; 182 | proxy_set_header Upgrade $http_upgrade; 183 | proxy_set_header Connection upgrade; 184 | proxy_pass http://YOURIP:8080/; 185 | } 186 | ``` 187 | 188 | To run it at /whiteboard. Don't forget to change -> YOURIP! 189 | 190 | ## Apache Reverse Proxy configuration 191 | 192 | ``` 193 | 194 | ... 195 | # Proxy /whiteboard/ to whiteboard container 196 | ProxyPass "/whiteboard/" "http://YOURIP:8080/" 197 | ProxyPassReverse "/whiteboard/" "http://YOURIP:8080/" 198 | ... 199 | 200 | ``` 201 | 202 | To run it at /whiteboard. Don't forget to change -> YOURIP! 203 | 204 | ## Nextcloud integration 205 | 206 | 1. Install this app on your server 207 | 2. Enable and go to "external sites" (app) on your Nextcloud 208 | 3. Add a link to your server: `https://YOURIP/whiteboard/?whiteboardid=WHITEBOARDNAME&username={uid}` 209 | You can give each group its own whiteboard by changeing the WHITEBOARDNAME in the URL if you want. 210 | 211 | Note: You might have to serve the app with https (If your nextcloud server runs https). To do so, its recommend to run this app behind a reverse proxy. (as shown above) 212 | 213 | #### (Optional) Set whiteboard icon in nextcloud 214 | 215 | ![start](https://raw.githubusercontent.com/cracker0dks/whiteboard/master/doc/iconPrev.jpg) 216 | 217 | Upload both icons present at /doc/nextcloud_icons/ to your nextcloud at the "external sites" admin section. Then set it as symbol on your link. 218 | 219 | **_ MIT License _** 220 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/assets/favicon.ico -------------------------------------------------------------------------------- /assets/images/bg_dots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/assets/images/bg_dots.png -------------------------------------------------------------------------------- /assets/images/bg_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/assets/images/bg_grid.png -------------------------------------------------------------------------------- /assets/images/bg_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/assets/images/bg_white.png -------------------------------------------------------------------------------- /assets/images/dottedRec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/assets/images/dottedRec.png -------------------------------------------------------------------------------- /assets/images/slider-background.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | BE12FC0E-FD5E-426C-9BBF-550FC50194C3 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /config.default.yml: -------------------------------------------------------------------------------- 1 | # Backend configuration 2 | backend: 3 | # Access token required for interacting with the server -- string (empty string for no restrictions) 4 | accessToken: "" 5 | 6 | # Enable the function to save to a webdav-server (check README for more info) -- boolean 7 | enableWebdav: false 8 | 9 | # Enable the function to save the whiteboard to a file so you save the state even on server restarts -- boolean 10 | enableFileDatabase: false 11 | 12 | # Backend performance tweaks 13 | performance: 14 | # Whiteboard information broadcasting frequency (in Hz i.e. /s) -- number 15 | # => diminishing this will result in more latency 16 | whiteboardInfoBroadcastFreq: 1 17 | 18 | # Frontend configuration 19 | frontend: 20 | # When a whiteboard is loaded on a client 21 | onWhiteboardLoad: 22 | # should an (editable) whiteboard be started in read-only mode by default -- boolean 23 | setReadOnly: false 24 | 25 | # should the whiteboard info be displayed by default -- boolean 26 | displayInfo: false 27 | 28 | # Show the smallest screen indicator ? (with dotted lines) -- boolean 29 | showSmallestScreenIndicator: true 30 | 31 | # Image download format, can be "png", "jpeg" (or "webp" -> only working on chrome) -- string 32 | imageDownloadFormat: "png" 33 | 34 | # if it is blank, images will use relative path, for example "/uploads/.png" 35 | imageURL: "" 36 | 37 | # draw the background grid to images on download ? (If True, even PNGs are also not transparent anymore) -- boolean 38 | drawBackgroundGrid: false 39 | 40 | # Background Image; Can be "bg_grid.png", "bg_dots.png" or "bg_white.png" (Place your background at assets/images if you want your own background) -- string 41 | backgroundGridImage: "bg_grid.png" 42 | 43 | # Frontend performance tweaks 44 | performance: 45 | # Refresh frequency of the debug / info div (in Hz i.e. /s) -- number 46 | refreshInfoFreq: 5 47 | 48 | # Throttling of pointer events (except drawing related) -- array of object (one must have fromUserCount == 0) 49 | # Throttling of events can be defined for different user count levels 50 | # Throttling consist of skipping certain events (i.e. not broadcasting them to others) 51 | pointerEventsThrottling: 52 | - # User count from which the specific throttling is applied -- number 53 | fromUserCount: 0 54 | # Min screen distance (in pixels) below which throttling is applied 55 | minDistDelta: 1 56 | # Maximum frequency above which throttling is applied 57 | maxFreq: 30 58 | - fromUserCount: 10 59 | minDistDelta: 5 60 | maxFreq: 10 61 | -------------------------------------------------------------------------------- /config/webpack.base.js: -------------------------------------------------------------------------------- 1 | import webpack from "webpack"; 2 | import { CleanWebpackPlugin } from "clean-webpack-plugin"; 3 | import CopyPlugin from "copy-webpack-plugin"; 4 | import HtmlWebpackPlugin from "html-webpack-plugin"; 5 | import path from "path"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | const config = { 12 | entry: { 13 | main: ["./src/js/index.js"], 14 | }, 15 | output: { 16 | path: path.join(__dirname, "..", "dist"), 17 | filename: "[name]-[fullhash].js", 18 | }, 19 | resolve: { 20 | extensions: [".*", ".json", ".js"], 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(js)$/, 26 | exclude: /node_modules/, 27 | loader: "babel-loader", 28 | options: { 29 | compact: true, 30 | }, 31 | }, 32 | { 33 | test: /\.json$/, 34 | type: "json", 35 | }, 36 | { 37 | test: /\.css$/, 38 | use: ["style-loader", "css-loader"], 39 | }, 40 | { 41 | test: /\.(png|jpe?g|gif|otf|pdf)$/i, 42 | use: [ 43 | { 44 | loader: "file-loader", 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | plugins: [ 51 | new CleanWebpackPlugin(), 52 | new webpack.ProvidePlugin({ 53 | $: "jquery", 54 | jQuery: "jquery", 55 | "window.jQuery": "jquery", 56 | "window.$": "jquery", 57 | }), 58 | new CopyPlugin({ patterns: [{ from: "assets", to: "" }] }), 59 | new HtmlWebpackPlugin({ 60 | template: "src/index.html", 61 | minify: false, 62 | inject: true, 63 | }), 64 | ], 65 | }; 66 | 67 | export { config as default }; 68 | -------------------------------------------------------------------------------- /config/webpack.build.js: -------------------------------------------------------------------------------- 1 | import { merge } from "webpack-merge"; 2 | import baseConfig from "./webpack.base.js"; 3 | 4 | export default merge(baseConfig, { 5 | mode: "production", 6 | performance: { 7 | hints: false, 8 | maxEntrypointSize: 512000, 9 | maxAssetSize: 512000, 10 | }, 11 | optimization: { 12 | minimize: true, 13 | nodeEnv: "production", 14 | }, 15 | devtool: false, 16 | }); 17 | -------------------------------------------------------------------------------- /config/webpack.dev.js: -------------------------------------------------------------------------------- 1 | import baseConfig from "./webpack.base.js"; 2 | import { merge } from "webpack-merge"; 3 | import webpack from "webpack"; 4 | 5 | const devConfig = merge(baseConfig, { 6 | mode: "development", 7 | devtool: "eval-source-map", 8 | optimization: { 9 | minimize: false, 10 | }, 11 | plugins: [new webpack.NoEmitOnErrorsPlugin()].concat(baseConfig.plugins), 12 | }); 13 | 14 | export { devConfig as default }; 15 | -------------------------------------------------------------------------------- /doc/iconPrev.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/doc/iconPrev.jpg -------------------------------------------------------------------------------- /doc/nextcloud_icons/whiteboard-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/doc/nextcloud_icons/whiteboard-dark.png -------------------------------------------------------------------------------- /doc/nextcloud_icons/whiteboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/doc/nextcloud_icons/whiteboard.png -------------------------------------------------------------------------------- /doc/start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cracker0dks/whiteboard/34fd627c045089db0d25a825c585c0c865c6ddd4/doc/start.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | services: 3 | whiteboard: 4 | image: rofl256/whiteboard 5 | restart: always 6 | ports: 7 | - "8080:8080/tcp" 8 | command: --config=./config.default.yml 9 | volumes: 10 | - ./data/uploads:/opt/app/public/uploads" 11 | - ./data/config.yml:/opt/app/config.default.yml:ro 12 | - ./data/savedBoards:/opt/app/savedBoards" 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whiteboard", 3 | "version": "1.0.0", 4 | "description": "Collaborative Whiteboard / Sketchboard", 5 | "main": "server.js", 6 | "directories": {}, 7 | "type": "module", 8 | "scripts": { 9 | "build": "webpack --config config/webpack.build.js", 10 | "start:dev": "apidoc -i scripts/ -o ./public/apidoc/ && node scripts/server.js --mode=development", 11 | "start:prod": "npm run build && npm run start", 12 | "start": "apidoc -i scripts/ -o ./dist/apidoc/ && node scripts/server.js --mode=production", 13 | "test": "jest", 14 | "pretty-quick": "pretty-quick", 15 | "format": "prettier --write .", 16 | "style": "prettier --check ." 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/cracker0dks/whiteboard" 21 | }, 22 | "keywords": [ 23 | "whiteboard", 24 | "Sketchboard", 25 | "lightweight" 26 | ], 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "pretty-quick --staged" 30 | } 31 | }, 32 | "dependencies": { 33 | "ajv": "^8.12.0", 34 | "apidoc": "^1.2.0", 35 | "dompurify": "^2.3.4", 36 | "express": "^4.21.1", 37 | "formidable": "^3.5.4", 38 | "fs-extra": "^11.1.1", 39 | "html2canvas": "^1.4.1", 40 | "jquery-ui-rotatable": "^1.1.0", 41 | "js-yaml": "3.13.1", 42 | "jsdom": "^16.5.0", 43 | "jsprim": "^1.4.2", 44 | "signature_pad": "^4.0.1", 45 | "socket.io": "^4.8.0", 46 | "socket.io-client": "^4.4.0", 47 | "uuid": "^9.0.1", 48 | "webdav": "^5.3.0" 49 | }, 50 | "devDependencies": { 51 | "@babel/cli": "^7.16.8", 52 | "@babel/core": "^7.16.12", 53 | "@babel/plugin-proposal-class-properties": "^7.16.7", 54 | "@babel/polyfill": "^7.8.7", 55 | "@babel/preset-env": "^7.16.11", 56 | "@fortawesome/fontawesome-svg-core": "^1.2.35", 57 | "@fortawesome/free-brands-svg-icons": "^5.15.3", 58 | "@fortawesome/free-regular-svg-icons": "^5.15.3", 59 | "@fortawesome/free-solid-svg-icons": "^5.15.3", 60 | "babel-loader": "^8.2.3", 61 | "babel-preset-minify": "^0.5.0", 62 | "clean-webpack-plugin": "^3.0.0", 63 | "copy-webpack-plugin": "^6.3.2", 64 | "css-loader": "^5.2.6", 65 | "html-webpack-plugin": "^5.5.0", 66 | "husky": "^8.0.3", 67 | "jest": "^27.4.7", 68 | "jquery": "^3.6.0", 69 | "jquery-ui": "^1.13.2", 70 | "keymage": "^1.1.3", 71 | "pdfjs-dist": "^4.2.67", 72 | "prettier": "^2.5.1", 73 | "pretty-quick": "^2.0.1", 74 | "style-loader": "^1.1.4", 75 | "vanilla-picker": "^2.12.1", 76 | "webpack": "^5.94.0", 77 | "webpack-cli": "^4.9.2", 78 | "webpack-dev-server": "^4.7.3", 79 | "webpack-merge": "^5.8.0" 80 | }, 81 | "author": "Cracker0dks", 82 | "license": "MIT", 83 | "private": true, 84 | "browserslist": { 85 | "production": [ 86 | ">0.2%", 87 | "not dead", 88 | "not op_mini all" 89 | ], 90 | "development": [ 91 | "last 1 chrome version", 92 | "last 1 firefox version", 93 | "last 1 safari version" 94 | ] 95 | }, 96 | "babel": { 97 | "presets": [ 98 | "@babel/preset-env" 99 | ], 100 | "plugins": [ 101 | [ 102 | "@babel/plugin-proposal-class-properties" 103 | ] 104 | ] 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /scripts/config/config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Whiteboard config", 4 | "type": "object", 5 | "properties": { 6 | "backend": { 7 | "type": "object", 8 | "required": ["accessToken", "performance", "enableWebdav"], 9 | "additionalProperties": false, 10 | "properties": { 11 | "accessToken": { 12 | "type": "string" 13 | }, 14 | "enableWebdav": { 15 | "type": "boolean" 16 | }, 17 | "enableFileDatabase": { 18 | "type": "boolean" 19 | }, 20 | "performance": { 21 | "additionalProperties": false, 22 | "type": "object", 23 | "required": ["whiteboardInfoBroadcastFreq"], 24 | "properties": { 25 | "whiteboardInfoBroadcastFreq": { 26 | "type": "number", 27 | "minimum": 0 28 | } 29 | } 30 | } 31 | } 32 | }, 33 | "frontend": { 34 | "type": "object", 35 | "additionalProperties": false, 36 | "required": ["onWhiteboardLoad", "showSmallestScreenIndicator", "performance"], 37 | "properties": { 38 | "onWhiteboardLoad": { 39 | "type": "object", 40 | "additionalProperties": false, 41 | "required": ["displayInfo", "setReadOnly"], 42 | "properties": { 43 | "setReadOnly": { 44 | "type": "boolean" 45 | }, 46 | "displayInfo": { 47 | "type": "boolean" 48 | } 49 | } 50 | }, 51 | "showSmallestScreenIndicator": { 52 | "type": "boolean" 53 | }, 54 | "imageDownloadFormat": { 55 | "type": "string" 56 | }, 57 | "imageURL": { 58 | "type": "string" 59 | }, 60 | "drawBackgroundGrid": { 61 | "type": "boolean" 62 | }, 63 | "backgroundGridImage": { 64 | "type": "string" 65 | }, 66 | "performance": { 67 | "type": "object", 68 | "additionalProperties": false, 69 | "required": ["pointerEventsThrottling", "refreshInfoFreq"], 70 | "properties": { 71 | "pointerEventsThrottling": { 72 | "type": "array", 73 | "minItems": 1, 74 | "items": { 75 | "type": "object", 76 | "additionalProperties": false, 77 | "required": ["fromUserCount", "minDistDelta", "maxFreq"], 78 | "properties": { 79 | "fromUserCount": { 80 | "type": "number", 81 | "minimum": 0 82 | }, 83 | "minDistDelta": { 84 | "type": "number", 85 | "minimum": 0 86 | }, 87 | "maxFreq": { 88 | "type": "number", 89 | "minimum": 0 90 | } 91 | } 92 | } 93 | }, 94 | "refreshInfoFreq": { 95 | "type": "number", 96 | "minimum": 0 97 | } 98 | } 99 | } 100 | } 101 | } 102 | }, 103 | "required": ["backend", "frontend"], 104 | "additionalProperties": false 105 | } 106 | -------------------------------------------------------------------------------- /scripts/config/config.js: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | 3 | import { getDefaultConfig, getConfig, deepMergeConfigs, isConfigValid } from "./utils.js"; 4 | 5 | import { getArgs } from "./../utils.js"; 6 | 7 | const defaultConfig = getDefaultConfig(); 8 | 9 | const cliArgs = getArgs(); 10 | let userConfig = {}; 11 | 12 | if (cliArgs["config"]) { 13 | userConfig = getConfig(cliArgs["config"]); 14 | } 15 | 16 | const config = deepMergeConfigs(defaultConfig, userConfig); 17 | 18 | /** 19 | * Update the config based on the CLI args 20 | * @param {object} startArgs 21 | */ 22 | function updateConfigFromStartArgs(startArgs) { 23 | function deprecateCliArg(key, callback) { 24 | const val = startArgs[key]; 25 | if (val) { 26 | console.warn( 27 | "\x1b[33m\x1b[1m", 28 | `Setting config values (${key}) from the CLI is deprecated. ` + 29 | "This ability will be removed in the next major version. " + 30 | "You should use the config file. " 31 | ); 32 | callback(val); 33 | } 34 | } 35 | 36 | deprecateCliArg("accesstoken", (val) => (config.backend.accessToken = val)); 37 | deprecateCliArg( 38 | "disablesmallestscreen", 39 | () => (config.backend.showSmallestScreenIndicator = false) 40 | ); 41 | deprecateCliArg("webdav", () => (config.backend.enableWebdav = true)); 42 | } 43 | 44 | /** 45 | * Update the config based on the env variables 46 | */ 47 | function updateConfigFromEnv() { 48 | function deprecateEnv(key, callback) { 49 | const val = process.env[key]; 50 | if (val) { 51 | console.warn( 52 | "\x1b[33m\x1b[1m", 53 | `Setting config values (${key}) from the environment is deprecated. ` + 54 | "This ability will be removed in the next major version. " + 55 | "You should use the config file. " 56 | ); 57 | callback(val); 58 | } 59 | } 60 | 61 | deprecateEnv("accesstoken", (val) => (config.backend.accessToken = val)); 62 | deprecateEnv( 63 | "disablesmallestscreen", 64 | () => (config.backend.showSmallestScreenIndicator = false) 65 | ); 66 | deprecateEnv("webdav", () => (config.backend.enableWebdav = true)); 67 | } 68 | 69 | // compatibility layer 70 | // FIXME: remove this in next major 71 | updateConfigFromEnv(); 72 | // FIXME: remove this in next major 73 | updateConfigFromStartArgs(cliArgs); 74 | 75 | if (!isConfigValid(config, true)) { 76 | throw new Error("Config is not valid. Check logs for details"); 77 | } 78 | 79 | if (!process.env.JEST_WORKER_ID) { 80 | console.info(util.inspect(config, { showHidden: false, depth: null, colors: true })); 81 | } 82 | 83 | export { config as default }; 84 | -------------------------------------------------------------------------------- /scripts/config/utils.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import yaml from "js-yaml"; 4 | 5 | import Ajv from "ajv"; 6 | const ajv = new Ajv({ allErrors: true }); 7 | 8 | import configSchema from "./config-schema.json" assert { type: "json" }; 9 | 10 | import { fileURLToPath } from "url"; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | 15 | /** 16 | * Load a yaml config file from a given path. 17 | * 18 | * @param path 19 | * @return {Object} 20 | */ 21 | export function getConfig(path) { 22 | return yaml.safeLoad(fs.readFileSync(path, "utf8")); 23 | } 24 | 25 | /** 26 | * Check that a config object is valid. 27 | * 28 | * @param {Object} config Config object 29 | * @param {boolean} warn Should we warn in console for errors 30 | * @return {boolean} 31 | */ 32 | export function isConfigValid(config, warn = true) { 33 | const validate = ajv.compile(configSchema); 34 | const isValidAgainstSchema = validate(config); 35 | 36 | if (!isValidAgainstSchema && warn) console.warn(validate.errors); 37 | 38 | let structureIsValid = false; 39 | try { 40 | structureIsValid = config.frontend.performance.pointerEventsThrottling.some( 41 | (item) => item.fromUserCount === 0 42 | ); 43 | } catch (e) { 44 | if (!e instanceof TypeError) { 45 | throw e; 46 | } 47 | } 48 | 49 | if (!structureIsValid && warn) 50 | console.warn( 51 | "At least one item under frontend.performance.pointerEventsThrottling" + 52 | "must have fromUserCount set to 0" 53 | ); 54 | 55 | return isValidAgainstSchema && structureIsValid; 56 | } 57 | 58 | /** 59 | * Load the default project config 60 | * @return {Object} 61 | */ 62 | export function getDefaultConfig() { 63 | const defaultConfigPath = path.join(__dirname, "..", "..", "config.default.yml"); 64 | return getConfig(defaultConfigPath); 65 | } 66 | 67 | /** 68 | * Deep merge of project config 69 | * 70 | * Objects are merged, not arrays 71 | * 72 | * @param baseConfig 73 | * @param overrideConfig 74 | * @return {Object} 75 | */ 76 | export function deepMergeConfigs(baseConfig, overrideConfig) { 77 | const out = {}; 78 | 79 | Object.entries(baseConfig).forEach(([key, val]) => { 80 | out[key] = val; 81 | if (overrideConfig.hasOwnProperty(key)) { 82 | const overrideVal = overrideConfig[key]; 83 | if (typeof val === "object" && !Array.isArray(val) && val !== null) { 84 | out[key] = deepMergeConfigs(val, overrideVal); 85 | } else { 86 | out[key] = overrideVal; 87 | } 88 | } 89 | }); 90 | 91 | return out; 92 | } 93 | -------------------------------------------------------------------------------- /scripts/config/utils.test.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig, deepMergeConfigs, isConfigValid } = require("./utils"); 2 | 3 | test("Load default config", () => { 4 | const defaultConfig = getDefaultConfig(); 5 | expect(typeof defaultConfig).toBe("object"); 6 | }); 7 | 8 | test("Full config override", () => { 9 | const defaultConfig = getDefaultConfig(); 10 | expect(deepMergeConfigs(defaultConfig, defaultConfig)).toEqual(defaultConfig); 11 | }); 12 | 13 | test("Simple partial config override", () => { 14 | expect(deepMergeConfigs({ test: true }, { test: false }).test).toBe(false); 15 | expect(deepMergeConfigs({ test: false }, { test: true }).test).toBe(true); 16 | }); 17 | 18 | test("Simple deep config override", () => { 19 | expect(deepMergeConfigs({ stage1: { stage2: true } }, { stage1: { stage2: false } })).toEqual({ 20 | stage1: { stage2: false }, 21 | }); 22 | }); 23 | 24 | test("Complex object config override", () => { 25 | expect( 26 | deepMergeConfigs({ stage1: { stage2: true, stage2b: true } }, { stage1: { stage2: false } }) 27 | ).toEqual({ 28 | stage1: { stage2: false, stage2b: true }, 29 | }); 30 | }); 31 | 32 | test("Override default config", () => { 33 | const defaultConfig = getDefaultConfig(); 34 | const overrideConfig1 = { frontend: { onWhiteboardLoad: { setReadOnly: true } } }; 35 | 36 | expect( 37 | deepMergeConfigs(defaultConfig, overrideConfig1).frontend.onWhiteboardLoad.setReadOnly 38 | ).toBe(true); 39 | }); 40 | 41 | test("Dumb config is not valid", () => { 42 | expect(isConfigValid({}, false)).toBe(false); 43 | }); 44 | 45 | test("Default config is valid", () => { 46 | const defaultConfig = getDefaultConfig(); 47 | expect(isConfigValid(defaultConfig)).toBe(true); 48 | }); 49 | -------------------------------------------------------------------------------- /scripts/s_whiteboard.js: -------------------------------------------------------------------------------- 1 | //This file is only for saving the whiteboard. 2 | import fs from "fs"; 3 | import config from "./config/config.js"; 4 | import { getSafeFilePath } from "./utils.js"; 5 | const FILE_DATABASE_FOLDER = "savedBoards"; 6 | 7 | var savedBoards = {}; 8 | var savedUndos = {}; 9 | var saveDelay = {}; 10 | 11 | if (config.backend.enableFileDatabase) { 12 | // make sure that folder with saved boards exists 13 | fs.mkdirSync(FILE_DATABASE_FOLDER, { 14 | // this option also mutes an error if path exists 15 | recursive: true, 16 | }); 17 | } 18 | 19 | /** 20 | * Get the file path for a whiteboard. 21 | * @param {string} wid Whiteboard id to get the path for 22 | * @returns {string} File path to the whiteboard 23 | * @throws {Error} if wid contains potentially unsafe directory characters 24 | */ 25 | function fileDatabasePath(wid) { 26 | return getSafeFilePath(FILE_DATABASE_FOLDER, wid + ".json"); 27 | } 28 | 29 | const s_whiteboard = { 30 | handleEventsAndData: function (content) { 31 | var tool = content["t"]; //Tool witch is used 32 | var wid = content["wid"]; //whiteboard ID 33 | var username = content["username"]; 34 | if (tool === "clear") { 35 | //Clear the whiteboard 36 | delete savedBoards[wid]; 37 | delete savedUndos[wid]; 38 | // delete the corresponding file too 39 | fs.unlink(fileDatabasePath(wid), function (err) { 40 | if (err) { 41 | return console.log(err); 42 | } 43 | }); 44 | } else if (tool === "undo") { 45 | //Undo an action 46 | if (!savedUndos[wid]) { 47 | savedUndos[wid] = []; 48 | } 49 | let savedBoard = this.loadStoredData(wid); 50 | if (savedBoard) { 51 | for (var i = savedBoards[wid].length - 1; i >= 0; i--) { 52 | if (savedBoards[wid][i]["username"] == username) { 53 | var drawId = savedBoards[wid][i]["drawId"]; 54 | for (var i = savedBoards[wid].length - 1; i >= 0; i--) { 55 | if ( 56 | savedBoards[wid][i]["drawId"] == drawId && 57 | savedBoards[wid][i]["username"] == username 58 | ) { 59 | savedUndos[wid].push(savedBoards[wid][i]); 60 | savedBoards[wid].splice(i, 1); 61 | } 62 | } 63 | break; 64 | } 65 | } 66 | if (savedUndos[wid].length > 1000) { 67 | savedUndos[wid].splice(0, savedUndos[wid].length - 1000); 68 | } 69 | } 70 | } else if (tool === "redo") { 71 | if (!savedUndos[wid]) { 72 | savedUndos[wid] = []; 73 | } 74 | let savedBoard = this.loadStoredData(wid); 75 | for (var i = savedUndos[wid].length - 1; i >= 0; i--) { 76 | if (savedUndos[wid][i]["username"] == username) { 77 | var drawId = savedUndos[wid][i]["drawId"]; 78 | for (var i = savedUndos[wid].length - 1; i >= 0; i--) { 79 | if ( 80 | savedUndos[wid][i]["drawId"] == drawId && 81 | savedUndos[wid][i]["username"] == username 82 | ) { 83 | savedBoard.push(savedUndos[wid][i]); 84 | savedUndos[wid].splice(i, 1); 85 | } 86 | } 87 | break; 88 | } 89 | } 90 | } else if ( 91 | [ 92 | "line", 93 | "pen", 94 | "rect", 95 | "circle", 96 | "eraser", 97 | "addImgBG", 98 | "recSelect", 99 | "eraseRec", 100 | "addTextBox", 101 | "setTextboxText", 102 | "removeTextbox", 103 | "setTextboxPosition", 104 | "setTextboxFontSize", 105 | "setTextboxFontColor", 106 | ].includes(tool) 107 | ) { 108 | let savedBoard = this.loadStoredData(wid); 109 | //Save all this actions 110 | delete content["wid"]; //Delete id from content so we don't store it twice 111 | if (tool === "setTextboxText") { 112 | for (var i = savedBoard.length - 1; i >= 0; i--) { 113 | //Remove old textbox tex -> dont store it twice 114 | if ( 115 | savedBoard[i]["t"] === "setTextboxText" && 116 | savedBoard[i]["d"][0] === content["d"][0] 117 | ) { 118 | savedBoard.splice(i, 1); 119 | } 120 | } 121 | } 122 | savedBoard.push(content); 123 | } 124 | this.saveToDB(wid); 125 | }, 126 | saveToDB: function (wid) { 127 | if (config.backend.enableFileDatabase) { 128 | //Save whiteboard to file 129 | if (!saveDelay[wid]) { 130 | saveDelay[wid] = true; 131 | setTimeout(function () { 132 | saveDelay[wid] = false; 133 | if (savedBoards[wid]) { 134 | fs.writeFile( 135 | fileDatabasePath(wid), 136 | JSON.stringify(savedBoards[wid]), 137 | (err) => { 138 | if (err) { 139 | return console.log(err); 140 | } 141 | } 142 | ); 143 | } 144 | }, 1000 * 10); //Save after 10 sec 145 | } 146 | } 147 | }, 148 | // Load saved whiteboard 149 | loadStoredData: function (wid) { 150 | if (wid in savedBoards) { 151 | return savedBoards[wid]; 152 | } 153 | 154 | savedBoards[wid] = []; 155 | 156 | // try to load from DB 157 | if (config.backend.enableFileDatabase) { 158 | //read saved board from file 159 | var filePath = fileDatabasePath(wid); 160 | if (fs.existsSync(filePath)) { 161 | var data = fs.readFileSync(filePath); 162 | if (data) { 163 | savedBoards[wid] = JSON.parse(data); 164 | } 165 | } 166 | } 167 | 168 | return savedBoards[wid]; 169 | }, 170 | copyStoredData: function (sourceWid, targetWid) { 171 | const sourceData = this.loadStoredData(sourceWid); 172 | if (sourceData.length === 0 || this.loadStoredData(targetWid).lenght > 0) { 173 | return; 174 | } 175 | savedBoards[targetWid] = sourceData.slice(); 176 | this.saveToDB(targetWid); 177 | }, 178 | saveData: function (wid, data) { 179 | const existingData = this.loadStoredData(wid); 180 | if (existingData.length > 0 || !data) { 181 | return; 182 | } 183 | savedBoards[wid] = JSON.parse(data); 184 | this.saveToDB(wid); 185 | }, 186 | }; 187 | 188 | export { s_whiteboard as default }; 189 | -------------------------------------------------------------------------------- /scripts/server-backend.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import config from "./config/config.js"; 4 | import ROBackendService from "./services/ReadOnlyBackendService.js"; 5 | const ReadOnlyBackendService = new ROBackendService(); 6 | import WBInfoBackendService from "./services/WhiteboardInfoBackendService.js"; 7 | const WhiteboardInfoBackendService = new WBInfoBackendService(); 8 | 9 | import { getSafeFilePath } from "./utils.js"; 10 | 11 | import fs from "fs-extra"; 12 | import express from "express"; 13 | import formidable from "formidable"; //form upload processing 14 | 15 | import createDOMPurify from "dompurify"; //Prevent xss 16 | import { JSDOM } from "jsdom"; 17 | 18 | import { createClient } from "webdav"; 19 | import s_whiteboard from "./s_whiteboard.js"; 20 | 21 | import http from "http"; 22 | import { Server } from "socket.io"; 23 | 24 | import { fileURLToPath } from "url"; 25 | 26 | const __filename = fileURLToPath(import.meta.url); 27 | const __dirname = path.dirname(__filename); 28 | 29 | export default function startBackendServer(port) { 30 | const window = new JSDOM("").window; 31 | const DOMPurify = createDOMPurify(window); 32 | 33 | var app = express(); 34 | 35 | var server = http.Server(app); 36 | server.listen(port); 37 | var io = new Server(server, { path: "/ws-api" }); 38 | WhiteboardInfoBackendService.start(io); 39 | 40 | console.log("socketserver running on port:" + port); 41 | 42 | const { accessToken, enableWebdav } = config.backend; 43 | 44 | //Expose static folders 45 | app.use(express.static(path.join(__dirname, "..", "dist"))); 46 | app.use("/uploads", express.static(path.join(__dirname, "..", "public", "uploads"))); 47 | 48 | /** 49 | * @api {get} /api/health Health Check 50 | * @apiDescription This returns nothing but a status code of 200 51 | * @apiName health 52 | * @apiGroup WhiteboardAPI 53 | * 54 | * @apiSuccess {Number} 200 OK 55 | */ 56 | app.get("/api/health", function (req, res) { 57 | res.status(200); //OK 58 | res.end(); 59 | }); 60 | 61 | /** 62 | * @api {get} /api/loadwhiteboard Get Whiteboard Data 63 | * @apiDescription This returns all the Available Data ever drawn to this Whiteboard 64 | * @apiName loadwhiteboard 65 | * @apiGroup WhiteboardAPI 66 | * 67 | * @apiParam {Number} wid WhiteboardId you find in the Whiteboard URL 68 | * @apiParam {Number} [at] Accesstoken (Only if activated for this server) 69 | * 70 | * @apiSuccess {String} body returns the data as JSON String 71 | * @apiError {Number} 401 Unauthorized 72 | * 73 | * @apiExample {curl} Example usage: 74 | * curl -i http://[rootUrl]/api/loadwhiteboard?wid=[MyWhiteboardId] 75 | */ 76 | app.get("/api/loadwhiteboard", function (req, res) { 77 | let query = escapeAllContentStrings(req["query"]); 78 | const wid = query["wid"]; 79 | const at = query["at"]; //accesstoken 80 | if (accessToken === "" || accessToken == at) { 81 | const widForData = ReadOnlyBackendService.isReadOnly(wid) 82 | ? ReadOnlyBackendService.getIdFromReadOnlyId(wid) 83 | : wid; 84 | const ret = s_whiteboard.loadStoredData(widForData); 85 | res.send(ret); 86 | res.end(); 87 | } else { 88 | res.status(401); //Unauthorized 89 | res.end(); 90 | } 91 | }); 92 | 93 | /** 94 | * @api {get} /api/getReadOnlyWid Get the readOnlyWhiteboardId 95 | * @apiDescription This returns the readOnlyWhiteboardId for a given WhiteboardId 96 | * @apiName getReadOnlyWid 97 | * @apiGroup WhiteboardAPI 98 | * 99 | * @apiParam {Number} wid WhiteboardId you find in the Whiteboard URL 100 | * @apiParam {Number} [at] Accesstoken (Only if activated for this server) 101 | * 102 | * @apiSuccess {String} body returns the readOnlyWhiteboardId as text 103 | * @apiError {Number} 401 Unauthorized 104 | * 105 | * @apiExample {curl} Example usage: 106 | * curl -i http://[rootUrl]/api/getReadOnlyWid?wid=[MyWhiteboardId] 107 | */ 108 | app.get("/api/getReadOnlyWid", function (req, res) { 109 | let query = escapeAllContentStrings(req["query"]); 110 | const wid = query["wid"]; 111 | const at = query["at"]; //accesstoken 112 | if (accessToken === "" || accessToken == at) { 113 | res.send(ReadOnlyBackendService.getReadOnlyId(wid)); 114 | res.end(); 115 | } else { 116 | res.status(401); //Unauthorized 117 | res.end(); 118 | } 119 | }); 120 | 121 | /** 122 | * @api {post} /api/upload Upload Images 123 | * @apiDescription Upload Image to the server. Note that you need to add the image to the board after upload by calling "drawToWhiteboard" with addImgBG set as tool 124 | * @apiName upload 125 | * @apiGroup WhiteboardAPI 126 | * 127 | * @apiParam {Number} wid WhiteboardId you find in the Whiteboard URL 128 | * @apiParam {Number} [at] Accesstoken (Only if activated for this server) 129 | * @apiParam {Number} [date] current timestamp (This is for the filename on the server; Don't set it if not sure) 130 | * @apiParam {Boolean} [webdavaccess] set true to upload to webdav (Optional; Only if activated for this server) 131 | * @apiParam {String} imagedata The imagedata base64 encoded 132 | * 133 | * @apiSuccess {String} body returns "done" 134 | * @apiError {Number} 401 Unauthorized 135 | */ 136 | app.post("/api/upload", function (req, res) { 137 | //File upload 138 | var form = formidable({}); //Receive form 139 | var formData = { 140 | files: {}, 141 | fields: {}, 142 | }; 143 | 144 | form.on("file", function (name, file) { 145 | formData["files"][file.name] = file; 146 | }); 147 | 148 | form.on("field", function (name, value) { 149 | formData["fields"][name] = value; 150 | }); 151 | 152 | form.on("error", function (err) { 153 | console.log("File uplaod Error!"); 154 | }); 155 | 156 | form.on("end", function () { 157 | if (accessToken === "" || accessToken == formData["fields"]["at"]) { 158 | progressUploadFormData(formData, function (err) { 159 | if (err) { 160 | if (err == "403") { 161 | res.status(403); 162 | } else { 163 | res.status(500); 164 | } 165 | res.end(); 166 | } else { 167 | res.send("done"); 168 | } 169 | }); 170 | } else { 171 | res.status(401); //Unauthorized 172 | res.end(); 173 | } 174 | //End file upload 175 | }); 176 | form.parse(req); 177 | }); 178 | 179 | /** 180 | * @api {get} /api/drawToWhiteboard Draw on the Whiteboard 181 | * @apiDescription Function draw on whiteboard with different tools and more... 182 | * @apiName drawToWhiteboard 183 | * @apiGroup WhiteboardAPI 184 | * 185 | * @apiParam {Number} wid WhiteboardId you find in the Whiteboard URL 186 | * @apiParam {Number} [at] Accesstoken (Only if activated for this server) 187 | * @apiParam {String} t The tool you want to use: "line", 188 | * "pen", 189 | * "rect", 190 | * "circle", 191 | * "eraser", 192 | * "addImgBG", 193 | * "recSelect", 194 | * "eraseRec", 195 | * "addTextBox", 196 | * "setTextboxText", 197 | * "removeTextbox", 198 | * "setTextboxPosition", 199 | * "setTextboxFontSize", 200 | * "setTextboxFontColor" 201 | * @apiParam {String} [username] The username performing this action. Only relevant for the undo/redo function 202 | * @apiParam {Number} [draw] Only has a function if t is set to "addImgBG". Set 1 to draw on canvas; 0 to draw into background 203 | * @apiParam {String} [url] Only has a function if t is set to "addImgBG", then it has to be set to: [rootUrl]/uploads/[ReadOnlyWid]/[ReadOnlyWid]_[date].png 204 | * @apiParam {String} [c] Color: Only used if color is needed (pen, rect, circle, addTextBox ... ) 205 | * @apiParam {String} [th] Thickness: Only used if Thickness is needed (pen, rect ... ) 206 | * @apiParam {Number[]} d has different function on every tool you use: 207 | * fx. pen or addImgBG: [width, height, left, top, rotation] 208 | * 209 | * @apiSuccess {String} body returns "done" as text 210 | * @apiError {Number} 401 Unauthorized 211 | * 212 | * @apiExample {curl} Example usage to draw a circle: 213 | * curl -i http://[rootUrl]/api/drawToWhiteboard?wid=[MyWhiteboardId]&t=circle&d=[388,201,100]&th=4 214 | */ 215 | app.get("/api/drawToWhiteboard", function (req, res) { 216 | let query = escapeAllContentStrings(req["query"]); 217 | const wid = query["wid"]; 218 | const at = query["at"]; //accesstoken 219 | if (!wid || ReadOnlyBackendService.isReadOnly(wid)) { 220 | res.status(401); //Unauthorized 221 | res.end(); 222 | } 223 | 224 | if (accessToken === "" || accessToken == at) { 225 | const broadcastTo = (wid) => io.compress(false).to(wid).emit("drawToWhiteboard", query); 226 | // broadcast to current whiteboard 227 | broadcastTo(wid); 228 | // broadcast the same query to the associated read-only whiteboard 229 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(wid); 230 | broadcastTo(readOnlyId); 231 | try { 232 | query.th = parseFloat(query.th); 233 | } catch (e) { 234 | //Dont do a thing 235 | } 236 | 237 | try { 238 | query.d = JSON.parse(query.d); 239 | } catch (e) { 240 | //Dont do a thing 241 | } 242 | s_whiteboard.handleEventsAndData(query); //save whiteboardchanges on the server 243 | res.send("done"); 244 | } else { 245 | res.status(401); //Unauthorized 246 | res.end(); 247 | } 248 | }); 249 | 250 | function progressUploadFormData(formData, callback) { 251 | console.log("Progress new Form Data"); 252 | const fields = escapeAllContentStrings(formData.fields); 253 | const wid = fields["wid"]; 254 | if (ReadOnlyBackendService.isReadOnly(wid)) return; 255 | 256 | const readOnlyWid = ReadOnlyBackendService.getReadOnlyId(wid); 257 | 258 | const date = fields["date"] || +new Date(); 259 | const filename = `${readOnlyWid}_${date}.png`; 260 | let webdavaccess = fields["webdavaccess"] || false; 261 | try { 262 | webdavaccess = JSON.parse(webdavaccess); 263 | } catch (e) { 264 | webdavaccess = false; 265 | } 266 | 267 | const savingDir = getSafeFilePath("public/uploads", readOnlyWid); 268 | fs.ensureDir(savingDir, function (err) { 269 | if (err) { 270 | console.log("Could not create upload folder!", err); 271 | return; 272 | } 273 | let imagedata = fields["imagedata"]; 274 | if (imagedata && imagedata != "") { 275 | //Save from base64 data 276 | imagedata = imagedata 277 | .replace(/^data:image\/png;base64,/, "") 278 | .replace(/^data:image\/jpeg;base64,/, ""); 279 | console.log(filename, "uploaded"); 280 | const savingPath = getSafeFilePath(savingDir, filename); 281 | fs.writeFile(savingPath, imagedata, "base64", function (err) { 282 | if (err) { 283 | console.log("error", err); 284 | callback(err); 285 | } else { 286 | if (webdavaccess) { 287 | //Save image to webdav 288 | if (enableWebdav) { 289 | saveImageToWebdav( 290 | savingPath, 291 | filename, 292 | webdavaccess, 293 | function (err) { 294 | if (err) { 295 | console.log("error", err); 296 | callback(err); 297 | } else { 298 | callback(); 299 | } 300 | } 301 | ); 302 | } else { 303 | callback("Webdav is not enabled on the server!"); 304 | } 305 | } else { 306 | callback(); 307 | } 308 | } 309 | }); 310 | } else { 311 | callback("no imagedata!"); 312 | console.log("No image Data found for this upload!", filename); 313 | } 314 | }); 315 | } 316 | 317 | function saveImageToWebdav(imagepath, filename, webdavaccess, callback) { 318 | if (webdavaccess) { 319 | const webdavserver = webdavaccess["webdavserver"] || ""; 320 | const webdavpath = webdavaccess["webdavpath"] || "/"; 321 | const webdavusername = webdavaccess["webdavusername"] || ""; 322 | const webdavpassword = webdavaccess["webdavpassword"] || ""; 323 | 324 | const client = createClient(webdavserver, { 325 | username: webdavusername, 326 | password: webdavpassword, 327 | }); 328 | client 329 | .getDirectoryContents(webdavpath) 330 | .then((items) => { 331 | const cloudpath = webdavpath + "" + filename; 332 | console.log("webdav saving to:", cloudpath); 333 | fs.createReadStream(imagepath).pipe(client.createWriteStream(cloudpath)); 334 | callback(); 335 | }) 336 | .catch((error) => { 337 | callback("403"); 338 | console.log("Could not connect to webdav!"); 339 | }); 340 | } else { 341 | callback("Error: no access data!"); 342 | } 343 | } 344 | 345 | io.on("connection", function (socket) { 346 | let whiteboardId = null; 347 | socket.on("disconnect", function () { 348 | WhiteboardInfoBackendService.leave(socket.id, whiteboardId); 349 | socket.compress(false).broadcast.to(whiteboardId).emit("refreshUserBadges", null); //Removes old user Badges 350 | }); 351 | 352 | socket.on("drawToWhiteboard", function (content) { 353 | if (!whiteboardId || ReadOnlyBackendService.isReadOnly(whiteboardId)) return; 354 | 355 | content = escapeAllContentStrings(content); 356 | content = purifyEncodedStrings(content); 357 | 358 | if (accessToken === "" || accessToken == content["at"]) { 359 | const broadcastTo = (wid) => 360 | socket.compress(false).broadcast.to(wid).emit("drawToWhiteboard", content); 361 | // broadcast to current whiteboard 362 | broadcastTo(whiteboardId); 363 | // broadcast the same content to the associated read-only whiteboard 364 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(whiteboardId); 365 | broadcastTo(readOnlyId); 366 | s_whiteboard.handleEventsAndData(content); //save whiteboardchanges on the server 367 | } else { 368 | socket.emit("wrongAccessToken", true); 369 | } 370 | }); 371 | 372 | socket.on("joinWhiteboard", function (content) { 373 | content = escapeAllContentStrings(content); 374 | if (accessToken === "" || accessToken == content["at"]) { 375 | whiteboardId = content["wid"]; 376 | 377 | socket.emit("whiteboardConfig", { 378 | common: config.frontend, 379 | whiteboardSpecific: { 380 | correspondingReadOnlyWid: 381 | ReadOnlyBackendService.getReadOnlyId(whiteboardId), 382 | isReadOnly: ReadOnlyBackendService.isReadOnly(whiteboardId), 383 | }, 384 | }); 385 | 386 | socket.join(whiteboardId); //Joins room name=wid 387 | const screenResolution = content["windowWidthHeight"]; 388 | WhiteboardInfoBackendService.join(socket.id, whiteboardId, screenResolution); 389 | } else { 390 | socket.emit("wrongAccessToken", true); 391 | } 392 | }); 393 | 394 | socket.on("updateScreenResolution", function (content) { 395 | content = escapeAllContentStrings(content); 396 | if (accessToken === "" || accessToken == content["at"]) { 397 | const screenResolution = content["windowWidthHeight"]; 398 | WhiteboardInfoBackendService.setScreenResolution( 399 | socket.id, 400 | whiteboardId, 401 | screenResolution 402 | ); 403 | } 404 | }); 405 | }); 406 | 407 | //Prevent cross site scripting (xss) 408 | function escapeAllContentStrings(content, cnt) { 409 | if (!cnt) cnt = 0; 410 | 411 | if (typeof content === "string") { 412 | return DOMPurify.sanitize(content); 413 | } 414 | for (var i in content) { 415 | if (typeof content[i] === "string") { 416 | content[i] = DOMPurify.sanitize(content[i]); 417 | } 418 | if (typeof content[i] === "object" && cnt < 10) { 419 | content[i] = escapeAllContentStrings(content[i], ++cnt); 420 | } 421 | } 422 | return content; 423 | } 424 | 425 | //Sanitize strings known to be encoded and decoded 426 | function purifyEncodedStrings(content) { 427 | if (content.hasOwnProperty("t") && content["t"] === "setTextboxText") { 428 | return purifyTextboxTextInContent(content); 429 | } 430 | return content; 431 | } 432 | 433 | function purifyTextboxTextInContent(content) { 434 | const raw = content["d"][1]; 435 | const decoded = base64decode(raw); 436 | const purified = DOMPurify.sanitize(decoded, { 437 | ALLOWED_TAGS: ["div", "br"], 438 | ALLOWED_ATTR: [], 439 | ALLOW_DATA_ATTR: false, 440 | }); 441 | 442 | if (purified !== decoded) { 443 | console.warn("setTextboxText payload needed be DOMpurified"); 444 | console.warn("raw: " + removeControlCharactersForLogs(raw)); 445 | console.warn("decoded: " + removeControlCharactersForLogs(decoded)); 446 | console.warn("purified: " + removeControlCharactersForLogs(purified)); 447 | } 448 | 449 | content["d"][1] = base64encode(purified); 450 | return content; 451 | } 452 | 453 | function base64encode(s) { 454 | return Buffer.from(s, "utf8").toString("base64"); 455 | } 456 | 457 | function base64decode(s) { 458 | return Buffer.from(s, "base64").toString("utf8"); 459 | } 460 | 461 | function removeControlCharactersForLogs(s) { 462 | return s.replace(/[\u0000-\u001F\u007F-\u009F]/g, ""); 463 | } 464 | 465 | process.on("unhandledRejection", (error) => { 466 | // Will print "unhandledRejection err is not defined" 467 | console.log("unhandledRejection", error.message); 468 | }); 469 | } 470 | -------------------------------------------------------------------------------- /scripts/server-frontend-dev.js: -------------------------------------------------------------------------------- 1 | import webpack from "webpack"; 2 | import config from "../config/webpack.dev.js"; 3 | import WebpackDevServer from "webpack-dev-server"; 4 | 5 | const devServerConfig = { 6 | proxy: { 7 | // proxies for the backend 8 | "/api": "http://localhost:3000", 9 | "/uploads": "http://localhost:3000", 10 | "/ws-api": { 11 | target: "ws://localhost:3000", 12 | ws: true, 13 | }, 14 | }, 15 | }; 16 | 17 | export async function startFrontendDevServer(port, resolve) { 18 | resolve(1); 19 | await new WebpackDevServer(webpack(config), devServerConfig).start(port, (err) => { 20 | if (err) { 21 | console.log(err); 22 | } 23 | }); 24 | console.log( 25 | "\n\n-------Successfully started dev server on http://localhost:8080-----------\n\n" 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /scripts/server.js: -------------------------------------------------------------------------------- 1 | import { getArgs } from "./utils.js"; 2 | import startBackendServer from "./server-backend.js"; 3 | 4 | const SERVER_MODES = { 5 | PRODUCTION: 1, 6 | DEVELOPMENT: 2, 7 | }; 8 | 9 | const args = getArgs(); 10 | 11 | if (typeof args.mode === "undefined") { 12 | // default to production mode 13 | args.mode = "production"; 14 | } 15 | 16 | if (args.mode !== "production" && args.mode !== "development") { 17 | throw new Error("--mode can only be 'development' or 'production'"); 18 | } 19 | 20 | const server_mode = args.mode === "production" ? SERVER_MODES.PRODUCTION : SERVER_MODES.DEVELOPMENT; 21 | 22 | if (server_mode === SERVER_MODES.DEVELOPMENT) { 23 | let startFrontendDevServer = (await import("./server-frontend-dev.js")).startFrontendDevServer; 24 | console.info("Starting server in development mode."); 25 | startFrontendDevServer(8080, function () { 26 | // this time, it's the frontend server that is on port 8080 27 | // requests for the backend will be proxied to prevent cross origins errors 28 | startBackendServer(3000); 29 | }); 30 | } else { 31 | console.info("Starting server in production mode."); 32 | startBackendServer(process.env.PORT || 8080); 33 | } 34 | -------------------------------------------------------------------------------- /scripts/services/ReadOnlyBackendService.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | export default class ReadOnlyBackendService { 4 | /** 5 | * Mapping from an editable whiteboard id to the matching read-only whiteboard id 6 | * @type {Map} 7 | * @private 8 | */ 9 | _idToReadOnlyId = new Map(); 10 | 11 | /** 12 | * Mapping from a read-only whiteboard id to the matching editable whiteboard id 13 | * 14 | * @type {Map} 15 | * @private 16 | */ 17 | _readOnlyIdToId = new Map(); 18 | 19 | /** 20 | * Make sure a whiteboardId is ignited in the service 21 | * 22 | * If it's not found in the service, we assume that it's an editable whiteboard 23 | * 24 | * @param {string} whiteboardId 25 | */ 26 | init(whiteboardId) { 27 | const idToReadOnlyId = this._idToReadOnlyId; 28 | const readOnlyIdToId = this._readOnlyIdToId; 29 | 30 | if (!idToReadOnlyId.has(whiteboardId) && !readOnlyIdToId.has(whiteboardId)) { 31 | const readOnlyId = uuidv4(); 32 | idToReadOnlyId.set(whiteboardId, readOnlyId); 33 | readOnlyIdToId.set(readOnlyId, whiteboardId); 34 | } 35 | } 36 | 37 | /** 38 | * Get the read-only id corresponding to a whiteboard id 39 | * 40 | * @param {string} whiteboardId 41 | * @return {string} 42 | */ 43 | getReadOnlyId(whiteboardId) { 44 | // make sure it's inited 45 | if (this.isReadOnly(whiteboardId)) return whiteboardId; 46 | // run in isReadOnly 47 | // this.init(whiteboardId); 48 | return this._idToReadOnlyId.get(whiteboardId); 49 | } 50 | 51 | /** 52 | * Get the id corresponding to readonly id 53 | * 54 | * @param {string} readOnlyId 55 | * @return {string} 56 | */ 57 | getIdFromReadOnlyId(readOnlyId) { 58 | return this._readOnlyIdToId.get(readOnlyId); 59 | } 60 | 61 | /** 62 | * Tell is whiteboard id corresponds to a read-only whiteboard 63 | * 64 | * @param whiteboardId 65 | * @return {boolean} 66 | */ 67 | isReadOnly(whiteboardId) { 68 | this.init(whiteboardId); 69 | return this._readOnlyIdToId.has(whiteboardId); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/services/WhiteboardInfoBackendService.js: -------------------------------------------------------------------------------- 1 | import config from "../config/config.js"; 2 | import ROnlyBackendService from "./ReadOnlyBackendService.js"; 3 | const ReadOnlyBackendService = new ROnlyBackendService(); 4 | 5 | /** 6 | * Class to hold information related to a whiteboard 7 | */ 8 | export class WhiteboardInfo { 9 | static defaultScreenResolution = { w: 1000, h: 1000 }; 10 | 11 | /** 12 | * @type {number} 13 | * @private 14 | */ 15 | #nbConnectedUsers = 0; 16 | get nbConnectedUsers() { 17 | return this.#nbConnectedUsers; 18 | } 19 | 20 | /** 21 | * @type {Map} 22 | * @private 23 | */ 24 | #screenResolutionByClients = new Map(); 25 | get screenResolutionByClients() { 26 | return this.#screenResolutionByClients; 27 | } 28 | 29 | /** 30 | * Variable to tell if these info have been sent or not 31 | * 32 | * @private 33 | * @type {boolean} 34 | */ 35 | #hasNonSentUpdates = false; 36 | get hasNonSentUpdates() { 37 | return this.#hasNonSentUpdates; 38 | } 39 | 40 | incrementNbConnectedUsers() { 41 | this.#nbConnectedUsers++; 42 | this.#hasNonSentUpdates = true; 43 | } 44 | 45 | decrementNbConnectedUsers() { 46 | this.#nbConnectedUsers--; 47 | this.#hasNonSentUpdates = true; 48 | } 49 | 50 | hasConnectedUser() { 51 | return this.#nbConnectedUsers > 0; 52 | } 53 | 54 | /** 55 | * Store information about the client's screen resolution 56 | * 57 | * @param {string} clientId 58 | * @param {number} w client's width 59 | * @param {number} h client's hight 60 | */ 61 | setScreenResolutionForClient(clientId, { w, h }) { 62 | this.#screenResolutionByClients.set(clientId, { w, h }); 63 | this.#hasNonSentUpdates = true; 64 | } 65 | 66 | /** 67 | * Delete the stored information about the client's screen resoltion 68 | * @param clientId 69 | */ 70 | deleteScreenResolutionOfClient(clientId) { 71 | this.#screenResolutionByClients.delete(clientId); 72 | this.#hasNonSentUpdates = true; 73 | } 74 | 75 | /** 76 | * Get the smallest client's screen size on a whiteboard 77 | * @return {{w: number, h: number}} 78 | */ 79 | getSmallestScreenResolution() { 80 | const { screenResolutionByClients: resolutions } = this; 81 | return { 82 | w: Math.min(...Array.from(resolutions.values()).map((res) => res.w)), 83 | h: Math.min(...Array.from(resolutions.values()).map((res) => res.h)), 84 | }; 85 | } 86 | 87 | infoWasSent() { 88 | this.#hasNonSentUpdates = false; 89 | } 90 | 91 | shouldSendInfo() { 92 | return this.#hasNonSentUpdates; 93 | } 94 | 95 | asObject() { 96 | const out = { 97 | nbConnectedUsers: this.#nbConnectedUsers, 98 | }; 99 | 100 | if (config.frontend.showSmallestScreenIndicator) { 101 | out.smallestScreenResolution = this.getSmallestScreenResolution(); 102 | } 103 | 104 | return out; 105 | } 106 | } 107 | 108 | /** 109 | * Wrapper class around map to treat both the editable whiteboard and its read-only version the same 110 | */ 111 | export class InfoByWhiteBoardMap extends Map { 112 | get(wid) { 113 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(wid); 114 | return super.get(readOnlyId); 115 | } 116 | 117 | set(wid, val) { 118 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(wid); 119 | return super.set(readOnlyId, val); 120 | } 121 | 122 | has(wid) { 123 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(wid); 124 | return super.has(readOnlyId); 125 | } 126 | 127 | delete(wid) { 128 | const readOnlyId = ReadOnlyBackendService.getReadOnlyId(wid); 129 | return super.delete(readOnlyId); 130 | } 131 | } 132 | 133 | export default class WhiteboardInfoBackendService { 134 | /** 135 | * @type {Map} 136 | */ 137 | #infoByWhiteboard = new InfoByWhiteBoardMap(); 138 | 139 | /** 140 | * Start the auto sending of information to all the whiteboards 141 | * 142 | * @param io 143 | */ 144 | start(io) { 145 | // auto clean infoByWhiteboard 146 | setInterval(() => { 147 | this.#infoByWhiteboard.forEach((info, readOnlyWhiteboardId) => { 148 | if (info.shouldSendInfo()) { 149 | // broadcast to editable whiteboard 150 | const wid = ReadOnlyBackendService.getIdFromReadOnlyId(readOnlyWhiteboardId); 151 | io.sockets 152 | .in(wid) 153 | .compress(false) 154 | .emit("whiteboardInfoUpdate", info.asObject()); 155 | 156 | // also send to readonly whiteboard 157 | io.sockets 158 | .in(readOnlyWhiteboardId) 159 | .compress(false) 160 | .emit("whiteboardInfoUpdate", info.asObject()); 161 | 162 | info.infoWasSent(); 163 | } 164 | }); 165 | }, (1 / config.backend.performance.whiteboardInfoBroadcastFreq) * 1000); 166 | } 167 | 168 | /** 169 | * Track a join event of client to a whiteboard 170 | * 171 | * @param {string} clientId 172 | * @param {string} whiteboardId 173 | * @param {{w: number, h: number}} screenResolution 174 | */ 175 | join(clientId, whiteboardId, screenResolution) { 176 | const infoByWhiteboard = this.#infoByWhiteboard; 177 | 178 | if (!infoByWhiteboard.has(whiteboardId)) { 179 | infoByWhiteboard.set(whiteboardId, new WhiteboardInfo()); 180 | } 181 | 182 | const whiteboardServerSideInfo = infoByWhiteboard.get(whiteboardId); 183 | whiteboardServerSideInfo.incrementNbConnectedUsers(); 184 | this.setScreenResolution(clientId, whiteboardId, screenResolution); 185 | } 186 | 187 | /** 188 | * Set the screen resolution of a client 189 | * @param {string} clientId 190 | * @param {string} whiteboardId 191 | * @param {{w: number, h: number}} screenResolution 192 | */ 193 | setScreenResolution(clientId, whiteboardId, screenResolution) { 194 | const infoByWhiteboard = this.#infoByWhiteboard; 195 | 196 | const whiteboardServerSideInfo = infoByWhiteboard.get(whiteboardId); 197 | if (whiteboardServerSideInfo) { 198 | whiteboardServerSideInfo.setScreenResolutionForClient( 199 | clientId, 200 | screenResolution || WhiteboardInfo.defaultScreenResolution 201 | ); 202 | } 203 | } 204 | 205 | /** 206 | * Track disconnect from a client 207 | * @param {string} clientId 208 | * @param {string} whiteboardId 209 | */ 210 | leave(clientId, whiteboardId) { 211 | const infoByWhiteboard = this.#infoByWhiteboard; 212 | 213 | if (infoByWhiteboard.has(whiteboardId)) { 214 | const whiteboardServerSideInfo = infoByWhiteboard.get(whiteboardId); 215 | 216 | if (clientId) { 217 | whiteboardServerSideInfo.deleteScreenResolutionOfClient(clientId); 218 | } 219 | 220 | whiteboardServerSideInfo.decrementNbConnectedUsers(); 221 | 222 | if (whiteboardServerSideInfo.hasConnectedUser()) { 223 | } else { 224 | infoByWhiteboard.delete(whiteboardId); 225 | } 226 | } 227 | } 228 | 229 | /** 230 | * Get the number of clients on a whiteboard 231 | * 232 | * @param {string} wid 233 | * @returns number|null 234 | */ 235 | getNbClientOnWhiteboard(wid) { 236 | const infoByWhiteboard = this.#infoByWhiteboard; 237 | const info = infoByWhiteboard.get(wid); 238 | 239 | if (info) return info.nbConnectedUsers; 240 | else return null; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /scripts/services/WhiteboardInfoBackendService.test.js: -------------------------------------------------------------------------------- 1 | const ReadOnlyBackendService = require("./ReadOnlyBackendService"); 2 | const WhiteboardInfoBackendService = require("./WhiteboardInfoBackendService"); 3 | 4 | test("Clients lifetime same wid", () => { 5 | const wid = "1"; 6 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(null); 7 | 8 | WhiteboardInfoBackendService.join("toto", wid, null); 9 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(1); 10 | 11 | WhiteboardInfoBackendService.join("tata", wid, null); 12 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(2); 13 | 14 | WhiteboardInfoBackendService.leave("tata", wid, null); 15 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(1); 16 | 17 | WhiteboardInfoBackendService.leave("toto", wid, null); 18 | // no more user on whiteboard 19 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(null); 20 | }); 21 | 22 | test("Clients lifetime both wid and readonly wid", () => { 23 | const wid = "2"; 24 | const readOnlyWid = ReadOnlyBackendService.getReadOnlyId(wid); 25 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(null); 26 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(readOnlyWid)).toBe(null); 27 | 28 | WhiteboardInfoBackendService.join("toto", wid, null); 29 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(1); 30 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(readOnlyWid)).toBe(1); 31 | 32 | WhiteboardInfoBackendService.join("tata", readOnlyWid, null); 33 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(2); 34 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(readOnlyWid)).toBe(2); 35 | 36 | WhiteboardInfoBackendService.leave("tata", readOnlyWid, null); 37 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(1); 38 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(readOnlyWid)).toBe(1); 39 | 40 | WhiteboardInfoBackendService.leave("toto", wid, null); 41 | // no more user on whiteboard 42 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(wid)).toBe(null); 43 | expect(WhiteboardInfoBackendService.getNbClientOnWhiteboard(readOnlyWid)).toBe(null); 44 | }); 45 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export function getArgs() { 4 | const args = {}; 5 | process.argv.slice(2, process.argv.length).forEach((arg) => { 6 | // long arg 7 | if (arg.slice(0, 2) === "--") { 8 | const longArg = arg.split("="); 9 | args[longArg[0].slice(2, longArg[0].length)] = longArg[1]; 10 | } 11 | // flags 12 | else if (arg[0] === "-") { 13 | const flags = arg.slice(1, arg.length).split(""); 14 | flags.forEach((flag) => { 15 | args[flag] = true; 16 | }); 17 | } 18 | }); 19 | return args; 20 | } 21 | 22 | /** 23 | * Creates a safe filepath given a trusted rootPath and untrusted singleFileSegment. 24 | * Prevents directory traversal attacks. 25 | * 26 | * @param {string} rootPath Root path - can be relative or absolute 27 | * @param {string} singleFileSegment A single file or folder segment - it should not have any path information 28 | * @return {string} A safe to use path combined of rootPath and singleFileSegment 29 | * @throws {Error} If singleFileSegment contains potentially unsafe directory characters or path information 30 | */ 31 | export function getSafeFilePath(rootPath, singleFileSegment) { 32 | var filePath = path.join(rootPath, singleFileSegment); 33 | if ( 34 | (path.dirname(filePath) !== rootPath && 35 | path.dirname(filePath) !== rootPath.replace("/", "\\")) || 36 | path.basename(filePath) !== singleFileSegment || 37 | path.normalize(singleFileSegment) !== singleFileSegment 38 | ) { 39 | var errorMessage = "Attempted path traversal attack: "; 40 | console.log(errorMessage, { 41 | rootPath: rootPath, 42 | singleFileSegment: singleFileSegment, 43 | }); 44 | throw new Error(errorMessage + singleFileSegment); 45 | } 46 | return filePath; 47 | } 48 | -------------------------------------------------------------------------------- /src/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --selected-icon-bg-color: #dfdfdf; 3 | } 4 | 5 | body { 6 | position: relative; 7 | margin: 0px; 8 | height: calc(var(--vh, 1vh) * 100); 9 | width: 100%; 10 | overflow: hidden; 11 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, 12 | Arial, sans-serif; 13 | } 14 | 15 | #whiteboardContainer { 16 | height: calc(var(--vh, 1vh) * 100); 17 | width: 100%; 18 | } 19 | 20 | .btn-group button { 21 | background: transparent; 22 | border: 2px solid #636060; 23 | margin: -1px; 24 | /* Green border */ 25 | color: black; 26 | /* White text */ 27 | padding: 11px 14px; 28 | /* Some padding */ 29 | cursor: pointer; 30 | /* Pointer/hand icon */ 31 | float: left; 32 | /* Float the buttons side by side */ 33 | } 34 | 35 | @media (max-device-width: 1024px) { 36 | .btn-group button { 37 | font-size: 2.4em; 38 | height: 88px; 39 | width: 88px; 40 | } 41 | 42 | .minGroup { 43 | width: 50px; 44 | } 45 | } 46 | 47 | @media (min-device-width: 1024px) { 48 | .btn-group button { 49 | font-size: 1.2em; 50 | height: 45px; 51 | width: 50px; 52 | } 53 | 54 | .minGroup { 55 | width: 25px; 56 | } 57 | } 58 | 59 | button::-moz-focus-inner { 60 | border: 0; 61 | } 62 | 63 | .whiteboard-edit-group.group-disabled { 64 | background: repeating-linear-gradient( 65 | 45deg, 66 | rgba(255, 166, 0, 0.366), 67 | rgba(255, 166, 0, 0.366) 10px, 68 | rgba(255, 166, 0, 0.666) 10px, 69 | rgba(255, 166, 0, 0.666) 20px 70 | ); 71 | } 72 | 73 | /* 74 | * Deactivate all pointer events on all the children 75 | * of a group when it's disabled. 76 | */ 77 | .whiteboard-edit-group.group-disabled > * { 78 | pointer-events: none; 79 | } 80 | 81 | /* Clear floats (clearfix hack) */ 82 | 83 | .btn-group:after { 84 | content: ""; 85 | clear: both; 86 | display: table; 87 | } 88 | 89 | /* Add a background color on hover */ 90 | 91 | .btn-group button:hover { 92 | background-color: #9a9a9a; 93 | } 94 | 95 | button { 96 | outline-width: 0; 97 | } 98 | 99 | .btn-group { 100 | background-color: #808080ab; 101 | margin-left: 5px; 102 | margin-bottom: 5px; 103 | float: left; 104 | position: relative; 105 | } 106 | 107 | .whiteboard-tool.active:not(:disabled) { 108 | background: var(--selected-icon-bg-color); 109 | } 110 | 111 | #whiteboardThicknessSlider { 112 | -webkit-appearance: none; 113 | width: 100%; 114 | height: 10px; 115 | border-radius: 3px; 116 | background: transparent; 117 | outline: none; 118 | opacity: 1; 119 | -webkit-transition: opacity 0.15s ease-in-out; 120 | transition: opacity 0.15s ease-in-out; 121 | } 122 | 123 | .textBox.active { 124 | border: 1px dashed gray; 125 | } 126 | 127 | .textBox > .removeIcon, 128 | .textBox > .moveIcon { 129 | display: none; 130 | } 131 | 132 | .textBox.active > .removeIcon, 133 | .textBox.active > .moveIcon { 134 | display: block; 135 | } 136 | 137 | .stickyNote { 138 | width: 200px; 139 | height: 200px; 140 | box-shadow: 5px 5px 7px rgba(33, 33, 33, 0.7); 141 | overflow-y: auto; 142 | } 143 | 144 | .modalBtn { 145 | padding: 5px; 146 | border-radius: 5px; 147 | border: 0px; 148 | min-width: 50px; 149 | cursor: pointer; 150 | } 151 | 152 | #displayWhiteboardInfoBtn.active { 153 | background: var(--selected-icon-bg-color); 154 | } 155 | 156 | #whiteboardInfoContainer { 157 | position: absolute; 158 | bottom: 10px; 159 | right: 10px; 160 | } 161 | 162 | .displayNone { 163 | display: none; 164 | } 165 | 166 | #shareWhiteboardDialog { 167 | width: 100vw; 168 | height: 100vh; 169 | background-color: rgba(1, 1, 1, 0.35); 170 | z-index: 10000000000000; 171 | position: absolute; 172 | top: 0; 173 | left: 0; 174 | } 175 | 176 | #shareWhiteboardDialogMessage { 177 | background-color: lightgreen; 178 | padding: 20px; 179 | font-weight: bold; 180 | } 181 | 182 | .shareWhiteboardDialogContent { 183 | display: flex; 184 | align-items: center; 185 | justify-content: center; 186 | flex-direction: column; 187 | width: 100%; 188 | height: 100%; 189 | } 190 | 191 | .shareWhiteboardDialogItem { 192 | padding: 5px; 193 | margin: 5px; 194 | } 195 | 196 | .picker_wrapper .picker_palette { 197 | width: 100%; 198 | order: 1; 199 | display: flex; 200 | margin-top: 0; 201 | margin-bottom: 0; 202 | flex-wrap: wrap; 203 | } 204 | 205 | .picker_wrapper .picker_splotch { 206 | /*flex:1;*/ 207 | width: 17px; 208 | height: 19px; 209 | margin: 4px 4px; 210 | box-shadow: 0 0 0 1px silver; 211 | border: 2px solid transparent; 212 | } 213 | 214 | .picker_wrapper .picker_splotch:hover { 215 | border: 2px solid black; 216 | } 217 | 218 | .picker_wrapper .picker_splotch.picker_splotch_active { 219 | border: 2px dotted yellow; 220 | } 221 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Whiteboard 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 |
15 |
16 | 24 | 27 |
28 | 29 |
30 | 33 | 41 | 44 | 47 |
48 | 49 |
50 | 58 |
59 | 60 |
61 | 64 | 65 | 74 | 82 | 91 | 94 | 97 | 100 |
101 | 102 |
103 | 143 |
144 | 145 |
146 | 149 | 157 | 176 |
177 | 178 |
179 | 192 | 193 | 211 | 212 | 213 |
214 | 215 |
216 | 229 | 247 | 265 | 266 | 269 | 270 | 273 |
274 | 275 |
276 | 293 |
294 |
295 | 296 |
297 |

Whiteboard information:

298 |

# connected users: 0

299 |

Smallest screen resolution: Unknown.

300 |

# msg. sent to server: 0

301 |

# msg. received from server: 0

302 |
303 | 304 |
305 |
306 | 312 | 318 | 321 |

325 |
326 |
327 | 328 | 329 | -------------------------------------------------------------------------------- /src/js/classes/Point.js: -------------------------------------------------------------------------------- 1 | import { computeDist } from "../utils.js"; 2 | 3 | class Point { 4 | /** 5 | * @type {number} 6 | */ 7 | #x; 8 | get x() { 9 | return this.#x; 10 | } 11 | 12 | /** 13 | * @type {number} 14 | */ 15 | #y; 16 | get y() { 17 | return this.#y; 18 | } 19 | 20 | /** 21 | * @type {Point} 22 | */ 23 | static #lastKnownPos = new Point(0, 0); 24 | static get lastKnownPos() { 25 | return Point.#lastKnownPos; 26 | } 27 | 28 | /** 29 | * @param {number} x 30 | * @param {number} y 31 | */ 32 | constructor(x, y) { 33 | this.#x = x; 34 | this.#y = y; 35 | } 36 | 37 | get isZeroZero() { 38 | return this.#x === 0 && this.#y === 0; 39 | } 40 | 41 | set x(newX) { 42 | this.#x = newX; 43 | } 44 | 45 | set y(newY) { 46 | this.#y = newY; 47 | } 48 | 49 | /** 50 | * Get a Point object from an event 51 | * @param {event} e 52 | * @returns {Point} 53 | */ 54 | static fromEvent(e) { 55 | // the epsilon hack is required to detect touches 56 | const epsilon = 0.0001; 57 | let x = (e.offsetX || e.pageX - $(e.target).offset().left) + epsilon; 58 | let y = (e.offsetY || e.pageY - $(e.target).offset().top) + epsilon; 59 | 60 | if (Number.isNaN(x) || Number.isNaN(y) || (x === epsilon && y === epsilon)) { 61 | // if it's a touch actually 62 | if (e.touches && e.touches.length && e.touches.length > 0) { 63 | const touch = e.touches[0]; 64 | x = touch.clientX - $("#mouseOverlay").offset().left; 65 | y = touch.clientY - $("#mouseOverlay").offset().top; 66 | } else { 67 | // if it's a touchend event 68 | return Point.#lastKnownPos; 69 | } 70 | } 71 | 72 | Point.#lastKnownPos = new Point(x - epsilon, y - epsilon); 73 | return Point.#lastKnownPos; 74 | } 75 | 76 | /** 77 | * Compute euclidean distance between points 78 | * 79 | * @param {Point} otherPoint 80 | * @returns {number} 81 | */ 82 | distTo(otherPoint) { 83 | return computeDist(this, otherPoint); 84 | } 85 | } 86 | 87 | export default Point; 88 | -------------------------------------------------------------------------------- /src/js/icons.js: -------------------------------------------------------------------------------- 1 | import { library, dom } from "@fortawesome/fontawesome-svg-core"; 2 | import { 3 | faUndo, 4 | faTrash, 5 | faCheck, 6 | faRedo, 7 | faMousePointer, 8 | faPencilAlt, 9 | faEraser, 10 | faImage, 11 | faFont, 12 | faSave, 13 | faUpload, 14 | faShareSquare, 15 | faAngleLeft, 16 | faAngleRight, 17 | faSortDown, 18 | faExpandArrowsAlt, 19 | faLock, 20 | faLockOpen, 21 | faInfoCircle, 22 | faGlobe, 23 | faStickyNote, 24 | faHandPaper, 25 | } from "@fortawesome/free-solid-svg-icons"; 26 | import { 27 | faSquare, 28 | faCircle, 29 | faFile, 30 | faFileAlt, 31 | faPlusSquare, 32 | } from "@fortawesome/free-regular-svg-icons"; 33 | 34 | library.add( 35 | faUndo, 36 | faTrash, 37 | faCheck, 38 | faRedo, 39 | faMousePointer, 40 | faPencilAlt, 41 | faEraser, 42 | faImage, 43 | faFont, 44 | faSave, 45 | faUpload, 46 | faShareSquare, 47 | faAngleLeft, 48 | faAngleRight, 49 | faSortDown, 50 | faExpandArrowsAlt, 51 | faSquare, 52 | faCircle, 53 | faFile, 54 | faFileAlt, 55 | faPlusSquare, 56 | faLock, 57 | faLockOpen, 58 | faInfoCircle, 59 | faGlobe, 60 | faStickyNote, 61 | faHandPaper 62 | ); 63 | 64 | dom.i2svg(); 65 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import "jquery-ui/ui/widgets/draggable.js"; 2 | import "jquery-ui/ui/widgets/resizable.js"; 3 | import "jquery-ui-rotatable/jquery.ui.rotatable.js"; 4 | import "jquery-ui/themes/base/resizable.css"; 5 | import "../css/main.css"; 6 | 7 | import "./icons.js"; 8 | import main from "./main.js"; 9 | 10 | $(function () { 11 | $("head").append( 12 | '' 13 | ); 14 | 15 | main(); 16 | }); 17 | -------------------------------------------------------------------------------- /src/js/keybinds.js: -------------------------------------------------------------------------------- 1 | /* ----------- 2 | KEYBINDINGS 3 | ----------- */ 4 | 5 | //> defmod is "command" on OS X and "ctrl" elsewhere 6 | //Advanced Example: 'defmod-k j' -> For this to fire you have to first press both ctrl and k, and then j. 7 | 8 | const keybinds = { 9 | // 'key(s)' : 'function', 10 | "defmod-shift-del": "clearWhiteboard", 11 | "defmod-z": "undoStep", 12 | "defmod-y": "redoStep", 13 | "defmod-x": "setTool_recSelect", 14 | "defmod-m": "setTool_mouse", 15 | "defmod-p": "setTool_pen", 16 | "defmod-l": "setTool_line", 17 | "defmod-r": "setTool_rect", 18 | "defmod-c": "setTool_circle", 19 | "defmod-space": "setTool_hand", 20 | "defmod-shift-f": "toggleLineRecCircle", 21 | "defmod-shift-x": "togglePenEraser", 22 | "defmod-shift-r": "toggleMainColors", 23 | "defmod-a": "setTool_text", 24 | "defmod-e": "setTool_eraser", 25 | "defmod-up": "thickness_bigger", 26 | "defmod-down": "thickness_smaller", 27 | "defmod-shift-c": "openColorPicker", 28 | "defmod-shift-1": "setDrawColorBlack", 29 | "defmod-shift-2": "setDrawColorBlue", 30 | "defmod-shift-3": "setDrawColorGreen", 31 | "defmod-shift-4": "setDrawColorYellow", 32 | "defmod-shift-5": "setDrawColorRed", 33 | "defmod-s": "saveWhiteboardAsImage", 34 | "defmod-shift-k": "saveWhiteboardAsJson", 35 | "defmod-shift-i": "uploadWhiteboardToWebDav", 36 | "defmod-shift-j": "uploadJsonToWhiteboard", 37 | "defmod-shift-s": "shareWhiteboard", 38 | tab: "hideShowControls", 39 | up: "moveDraggableUp", 40 | down: "moveDraggableDown", 41 | left: "moveDraggableLeft", 42 | right: "moveDraggableRight", 43 | "defmod-enter": "dropDraggable", 44 | "shift-enter": "addToBackground", 45 | escape: "cancelAllActions", 46 | del: "deleteSelection", 47 | }; 48 | 49 | export default keybinds; 50 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import keymage from "keymage"; 2 | import { io } from "socket.io-client"; 3 | import whiteboard from "./whiteboard.js"; 4 | import keybinds from "./keybinds.js"; 5 | import Picker from "vanilla-picker"; 6 | import { dom } from "@fortawesome/fontawesome-svg-core"; 7 | import shortcutFunctions from "./shortcutFunctions.js"; 8 | import ReadOnlyService from "./services/ReadOnlyService.js"; 9 | import InfoService from "./services/InfoService.js"; 10 | import { getSubDir } from "./utils.js"; 11 | import ConfigService from "./services/ConfigService.js"; 12 | import { v4 as uuidv4 } from "uuid"; 13 | 14 | import * as pdfjsLib from "pdfjs-dist/webpack.mjs"; 15 | 16 | const urlParams = new URLSearchParams(window.location.search); 17 | let whiteboardId = urlParams.get("whiteboardid"); 18 | const randomid = urlParams.get("randomid"); 19 | 20 | if (randomid) { 21 | whiteboardId = uuidv4(); 22 | urlParams.delete("randomid"); 23 | window.location.search = urlParams; 24 | } 25 | 26 | if (!whiteboardId) { 27 | whiteboardId = "myNewWhiteboard"; 28 | } 29 | 30 | whiteboardId = unescape(encodeURIComponent(whiteboardId)).replace(/[^a-zA-Z0-9\-]/g, ""); 31 | 32 | if (urlParams.get("whiteboardid") !== whiteboardId) { 33 | urlParams.set("whiteboardid", whiteboardId); 34 | window.location.search = urlParams; 35 | } 36 | 37 | const myUsername = urlParams.get("username") || "unknown" + (Math.random() + "").substring(2, 6); 38 | const accessToken = urlParams.get("accesstoken") || ""; 39 | const copyfromwid = urlParams.get("copyfromwid") || ""; 40 | 41 | // Custom Html Title 42 | const title = urlParams.get("title"); 43 | if (title) { 44 | document.title = decodeURIComponent(title); 45 | } 46 | 47 | const subdir = getSubDir(); 48 | let signaling_socket; 49 | 50 | function main() { 51 | signaling_socket = io("", { path: subdir + "/ws-api" }); // Connect even if we are in a subdir behind a reverse proxy 52 | 53 | signaling_socket.on("connect", function () { 54 | console.log("Websocket connected!"); 55 | 56 | signaling_socket.on("whiteboardConfig", (serverResponse) => { 57 | ConfigService.initFromServer(serverResponse); 58 | // Inti whiteboard only when we have the config from the server 59 | initWhiteboard(); 60 | }); 61 | 62 | signaling_socket.on("whiteboardInfoUpdate", (info) => { 63 | InfoService.updateInfoFromServer(info); 64 | whiteboard.updateSmallestScreenResolution(); 65 | }); 66 | 67 | signaling_socket.on("drawToWhiteboard", function (content) { 68 | whiteboard.handleEventsAndData(content, true); 69 | InfoService.incrementNbMessagesReceived(); 70 | }); 71 | 72 | signaling_socket.on("refreshUserBadges", function () { 73 | whiteboard.refreshUserBadges(); 74 | }); 75 | 76 | let accessDenied = false; 77 | signaling_socket.on("wrongAccessToken", function () { 78 | if (!accessDenied) { 79 | accessDenied = true; 80 | showBasicAlert("Access denied! Wrong accessToken!"); 81 | } 82 | }); 83 | 84 | signaling_socket.emit("joinWhiteboard", { 85 | wid: whiteboardId, 86 | at: accessToken, 87 | windowWidthHeight: { w: $(window).width(), h: $(window).height() }, 88 | }); 89 | }); 90 | } 91 | 92 | function showBasicAlert(html, newOptions) { 93 | var options = { 94 | header: "INFO MESSAGE", 95 | okBtnText: "Ok", 96 | headercolor: "#d25d5d", 97 | hideAfter: false, 98 | onOkClick: false, 99 | }; 100 | if (newOptions) { 101 | for (var i in newOptions) { 102 | options[i] = newOptions[i]; 103 | } 104 | } 105 | var alertHtml = $( 106 | '
' + 107 | '
' + 108 | '
' + 111 | options["header"] + 112 | '
x
' + 113 | '
' + 114 | '
" + 117 | "
" + 118 | "
" 119 | ); 120 | alertHtml.find(".htmlcontent").append(html); 121 | $("body").append(alertHtml); 122 | alertHtml 123 | .find(".okbtn") 124 | .off("click") 125 | .click(function () { 126 | if (options.onOkClick) { 127 | options.onOkClick(); 128 | } 129 | alertHtml.remove(); 130 | }); 131 | alertHtml 132 | .find(".closeAlert") 133 | .off("click") 134 | .click(function () { 135 | alertHtml.remove(); 136 | }); 137 | 138 | if (options.hideAfter) { 139 | setTimeout(function () { 140 | alertHtml.find(".okbtn").click(); 141 | }, 1000 * options.hideAfter); 142 | } 143 | } 144 | 145 | function initWhiteboard() { 146 | $(document).ready(function () { 147 | // by default set in readOnly mode 148 | ReadOnlyService.activateReadOnlyMode(); 149 | 150 | if (urlParams.get("webdav") === "true") { 151 | $("#uploadWebDavBtn").show(); 152 | } 153 | 154 | whiteboard.loadWhiteboard("#whiteboardContainer", { 155 | //Load the whiteboard 156 | whiteboardId: whiteboardId, 157 | username: btoa(encodeURIComponent(myUsername)), 158 | backgroundGridUrl: "./images/" + ConfigService.backgroundGridImage, 159 | sendFunction: function (content) { 160 | if (ReadOnlyService.readOnlyActive) return; 161 | //ADD IN LATER THROUGH CONFIG 162 | // if (content.t === 'cursor') { 163 | // if (whiteboard.drawFlag) return; 164 | // } 165 | content["at"] = accessToken; 166 | signaling_socket.emit("drawToWhiteboard", content); 167 | InfoService.incrementNbMessagesSent(); 168 | }, 169 | }); 170 | 171 | // request whiteboard from server 172 | $.get(subdir + "/api/loadwhiteboard", { wid: whiteboardId, at: accessToken }).done( 173 | function (data) { 174 | //console.log(data); 175 | whiteboard.loadData(data); 176 | if (copyfromwid && data.length == 0) { 177 | //Copy from witheboard if current is empty and get parameter is given 178 | $.get(subdir + "/api/loadwhiteboard", { 179 | wid: copyfromwid, 180 | at: accessToken, 181 | }).done(function (data) { 182 | whiteboard.loadData(data); 183 | }); 184 | } 185 | } 186 | ); 187 | 188 | $(window).resize(function () { 189 | signaling_socket.emit("updateScreenResolution", { 190 | at: accessToken, 191 | windowWidthHeight: { w: $(window).width(), h: $(window).height() }, 192 | }); 193 | }); 194 | 195 | /*----------------/ 196 | Whiteboard actions 197 | /----------------*/ 198 | 199 | var tempLineTool = false; 200 | var strgPressed = false; 201 | //Handle key actions 202 | $(document).on("keydown", function (e) { 203 | if (e.which == 16) { 204 | if (whiteboard.tool == "pen" && !strgPressed) { 205 | tempLineTool = true; 206 | whiteboard.ownCursor.hide(); 207 | if (whiteboard.drawFlag) { 208 | whiteboard.mouseup({ 209 | offsetX: whiteboard.prevPos.x, 210 | offsetY: whiteboard.prevPos.y, 211 | }); 212 | shortcutFunctions.setTool_line(); 213 | whiteboard.mousedown({ 214 | offsetX: whiteboard.prevPos.x, 215 | offsetY: whiteboard.prevPos.y, 216 | }); 217 | } else { 218 | shortcutFunctions.setTool_line(); 219 | } 220 | } 221 | whiteboard.pressedKeys["shift"] = true; //Used for straight lines... 222 | } else if (e.which == 17) { 223 | strgPressed = true; 224 | } 225 | //console.log(e.which); 226 | }); 227 | $(document).on("keyup", function (e) { 228 | if (e.which == 16) { 229 | if (tempLineTool) { 230 | tempLineTool = false; 231 | shortcutFunctions.setTool_pen(); 232 | whiteboard.ownCursor.show(); 233 | } 234 | whiteboard.pressedKeys["shift"] = false; 235 | } else if (e.which == 17) { 236 | strgPressed = false; 237 | } 238 | }); 239 | 240 | //Load keybindings from keybinds.js to given functions 241 | Object.entries(keybinds).forEach(([key, functionName]) => { 242 | const associatedShortcutFunction = shortcutFunctions[functionName]; 243 | if (associatedShortcutFunction) { 244 | keymage(key, associatedShortcutFunction, { preventDefault: true }); 245 | } else { 246 | console.error( 247 | "Function you want to keybind on key:", 248 | key, 249 | "named:", 250 | functionName, 251 | "is not available!" 252 | ); 253 | } 254 | }); 255 | 256 | // whiteboard clear button 257 | $("#whiteboardTrashBtn") 258 | .off("click") 259 | .click(function () { 260 | $("#whiteboardTrashBtnConfirm").show().focus(); 261 | $(this).hide(); 262 | }); 263 | 264 | $("#whiteboardTrashBtnConfirm").mouseout(function () { 265 | $(this).hide(); 266 | $("#whiteboardTrashBtn").show(); 267 | }); 268 | 269 | $("#whiteboardTrashBtnConfirm") 270 | .off("click") 271 | .click(function () { 272 | $(this).hide(); 273 | $("#whiteboardTrashBtn").show(); 274 | whiteboard.clearWhiteboard(); 275 | }); 276 | 277 | // undo button 278 | $("#whiteboardUndoBtn") 279 | .off("click") 280 | .click(function () { 281 | whiteboard.undoWhiteboardClick(); 282 | }); 283 | 284 | // redo button 285 | $("#whiteboardRedoBtn") 286 | .off("click") 287 | .click(function () { 288 | whiteboard.redoWhiteboardClick(); 289 | }); 290 | 291 | // view only 292 | $("#whiteboardLockBtn") 293 | .off("click") 294 | .click(() => { 295 | ReadOnlyService.deactivateReadOnlyMode(); 296 | }); 297 | $("#whiteboardUnlockBtn") 298 | .off("click") 299 | .click(() => { 300 | ReadOnlyService.activateReadOnlyMode(); 301 | }); 302 | $("#whiteboardUnlockBtn").hide(); 303 | $("#whiteboardLockBtn").show(); 304 | 305 | // switch tool 306 | $(".whiteboard-tool") 307 | .off("click") 308 | .click(function () { 309 | $(".whiteboard-tool").removeClass("active"); 310 | $(this).addClass("active"); 311 | var activeTool = $(this).attr("tool"); 312 | whiteboard.setTool(activeTool); 313 | if (activeTool == "mouse" || activeTool == "recSelect") { 314 | $(".activeToolIcon").empty(); 315 | } else { 316 | $(".activeToolIcon").html($(this).html()); //Set Active icon the same as the button icon 317 | } 318 | 319 | if (activeTool == "text" || activeTool == "stickynote") { 320 | $("#textboxBackgroundColorPickerBtn").show(); 321 | } else { 322 | $("#textboxBackgroundColorPickerBtn").hide(); 323 | } 324 | let savedThickness = localStorage.getItem("item_thickness_" + activeTool); 325 | if (savedThickness) { 326 | whiteboard.setStrokeThickness(savedThickness); 327 | $("#whiteboardThicknessSlider").val(savedThickness); 328 | } 329 | }); 330 | 331 | // upload image button 332 | $("#addImgToCanvasBtn") 333 | .off("click") 334 | .click(function () { 335 | if (ReadOnlyService.readOnlyActive) return; 336 | showBasicAlert(`Please drag the image into the browser.
337 | Or upload here: `); 338 | document.getElementById("manualFileUpload").addEventListener( 339 | "change", 340 | function (e) { 341 | $(".basicalert").remove(); 342 | if (ReadOnlyService.readOnlyActive) return; 343 | e.originalEvent = { dataTransfer: { files: e.target.files } }; 344 | handleFileUploadEvent(e); 345 | }, 346 | false 347 | ); 348 | }); 349 | 350 | // save image as imgae 351 | $("#saveAsImageBtn") 352 | .off("click") 353 | .click(function () { 354 | whiteboard.getImageDataBase64( 355 | { 356 | imageFormat: ConfigService.imageDownloadFormat, 357 | drawBackgroundGrid: ConfigService.drawBackgroundGrid, 358 | }, 359 | function (imgData) { 360 | var w = window.open("about:blank"); //Firefox will not allow downloads without extra window 361 | setTimeout(function () { 362 | //FireFox seems to require a setTimeout for this to work. 363 | var a = document.createElement("a"); 364 | a.href = imgData; 365 | a.download = "whiteboard." + ConfigService.imageDownloadFormat; 366 | w.document.body.appendChild(a); 367 | a.click(); 368 | w.document.body.removeChild(a); 369 | setTimeout(function () { 370 | w.close(); 371 | }, 100); 372 | }, 0); 373 | } 374 | ); 375 | }); 376 | 377 | // save image to json containing steps 378 | $("#saveAsJSONBtn") 379 | .off("click") 380 | .click(function () { 381 | var imgData = whiteboard.getImageDataJson(); 382 | 383 | var w = window.open("about:blank"); //Firefox will not allow downloads without extra window 384 | setTimeout(function () { 385 | //FireFox seems to require a setTimeout for this to work. 386 | var a = document.createElement("a"); 387 | a.href = window.URL.createObjectURL(new Blob([imgData], { type: "text/json" })); 388 | a.download = "whiteboard.json"; 389 | w.document.body.appendChild(a); 390 | a.click(); 391 | w.document.body.removeChild(a); 392 | setTimeout(function () { 393 | w.close(); 394 | }, 100); 395 | }, 0); 396 | }); 397 | 398 | $("#uploadWebDavBtn") 399 | .off("click") 400 | .click(function () { 401 | if ($(".webdavUploadBtn").length > 0) { 402 | return; 403 | } 404 | 405 | var webdavserver = localStorage.getItem("webdavserver") || ""; 406 | var webdavpath = localStorage.getItem("webdavpath") || "/"; 407 | var webdavusername = localStorage.getItem("webdavusername") || ""; 408 | var webdavpassword = localStorage.getItem("webdavpassword") || ""; 409 | var webDavHtml = $( 410 | `
411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 436 | 437 | 438 | 439 | 442 | 443 |
Server URL:
Path:path always have to start & end with "/"
Username:
Password:
434 | Note: You have to generate and use app credentials if you have 2 Factor Auth activated on your dav/nextcloud server! 435 |
440 | 441 |
444 |
` 445 | ); 446 | webDavHtml 447 | .find(".webdavUploadBtn") 448 | .off("click") 449 | .click(function () { 450 | var webdavserver = webDavHtml.find(".webdavserver").val(); 451 | localStorage.setItem("webdavserver", webdavserver); 452 | var webdavpath = webDavHtml.find(".webdavpath").val(); 453 | localStorage.setItem("webdavpath", webdavpath); 454 | var webdavusername = webDavHtml.find(".webdavusername").val(); 455 | localStorage.setItem("webdavusername", webdavusername); 456 | var webdavpassword = webDavHtml.find(".webdavpassword").val(); 457 | localStorage.setItem("webdavpassword", webdavpassword); 458 | whiteboard.getImageDataBase64( 459 | { 460 | imageFormat: ConfigService.imageDownloadFormat, 461 | drawBackgroundGrid: ConfigService.drawBackgroundGrid, 462 | }, 463 | function (base64data) { 464 | var webdavaccess = { 465 | webdavserver: webdavserver, 466 | webdavpath: webdavpath, 467 | webdavusername: webdavusername, 468 | webdavpassword: webdavpassword, 469 | }; 470 | webDavHtml.find(".loadingWebdavText").show(); 471 | webDavHtml.find(".webdavUploadBtn").hide(); 472 | saveWhiteboardToWebdav(base64data, webdavaccess, function (err) { 473 | if (err) { 474 | webDavHtml.find(".loadingWebdavText").hide(); 475 | webDavHtml.find(".webdavUploadBtn").show(); 476 | } else { 477 | webDavHtml.parents(".basicalert").remove(); 478 | } 479 | }); 480 | } 481 | ); 482 | }); 483 | showBasicAlert(webDavHtml, { 484 | header: "Save to Webdav", 485 | okBtnText: "cancel", 486 | headercolor: "#0082c9", 487 | }); 488 | // render newly added icons 489 | dom.i2svg(); 490 | }); 491 | 492 | // upload json containing steps 493 | $("#uploadJsonBtn") 494 | .off("click") 495 | .click(function () { 496 | $("#myFile").click(); 497 | }); 498 | 499 | $("#shareWhiteboardBtn") 500 | .off("click") 501 | .click(() => { 502 | function urlToClipboard(whiteboardId = null) { 503 | const { protocol, host, pathname, search } = window.location; 504 | const basePath = `${protocol}//${host}${pathname}`; 505 | const getParams = new URLSearchParams(search); 506 | 507 | // Clear ursername from get parameters 508 | getParams.delete("username"); 509 | 510 | if (whiteboardId) { 511 | // override whiteboardId value in URL 512 | getParams.set("whiteboardid", whiteboardId); 513 | } 514 | 515 | const url = `${basePath}?${getParams.toString()}`; 516 | $("