├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── .huskyrc.js ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE.txt ├── README.md ├── backend ├── migrations │ ├── 001_init.js │ ├── 002_add_first_serial.js │ ├── 003_refactor_access.sql │ ├── 004_public_boards_access.sql │ ├── 005_uuid_extension.sql │ ├── 006_unique_email.sql │ ├── 007_add_crdt_update.sql │ ├── 008_allow_crdt_only_bundle.sql │ ├── 009_drop_board_event_primary_key.sql │ └── 010_drop_history_column.sql ├── package.json ├── src │ ├── api │ │ ├── api-routes.openapi.ts │ │ ├── api-routes.ts │ │ ├── board-create.ts │ │ ├── board-csv-get.ts │ │ ├── board-get.ts │ │ ├── board-hierarchy-get.ts │ │ ├── board-history-get.ts │ │ ├── board-update.ts │ │ ├── github-webhook.ts │ │ ├── item-create-or-update.ts │ │ ├── item-create.ts │ │ └── utils.ts │ ├── board-event-handler.ts │ ├── board-state.test.ts │ ├── board-state.ts │ ├── board-store.ts │ ├── board-yjs-server.ts │ ├── common-event-handler.ts │ ├── compact-history.ts │ ├── config.ts │ ├── connection-handler.ts │ ├── db.ts │ ├── decodeOrThrow.ts │ ├── env.ts │ ├── expiring-map.ts │ ├── express-server.ts │ ├── generic-oidc-auth.ts │ ├── github-webhook │ │ └── example-payload.json │ ├── google-auth.ts │ ├── host-config.ts │ ├── http-session.ts │ ├── locker.ts │ ├── oauth.ts │ ├── openapi.ts │ ├── professions.ts │ ├── require-auth.ts │ ├── s3.ts │ ├── server.ts │ ├── storage.ts │ ├── tools │ │ └── wait-for-db.ts │ ├── user-store.ts │ ├── uwebsockets-server.ts │ ├── websocket-sessions.ts │ ├── ws-wrapper.ts │ └── y-websocket-server │ │ ├── Docs.ts │ │ ├── Persistence.ts │ │ ├── Protocol.ts │ │ ├── WSSharedDoc.ts │ │ └── YWebSocketServer.ts └── tsconfig.json ├── benchmark └── benchmark.ts ├── common └── src │ ├── action-folding.ts │ ├── arrays.ts │ ├── assertNotNull.ts │ ├── authenticated-user.ts │ ├── board-crdt-helper.ts │ ├── board-reducer.benchmark.ts │ ├── board-reducer.ts │ ├── colors.ts │ ├── connection-utils.ts │ ├── domain.ts │ ├── geometry.ts │ ├── migration.test.ts │ ├── migration.ts │ ├── sets.ts │ ├── sleep.ts │ └── vector2.ts ├── cypress.json ├── docker-compose.yaml ├── frontend ├── .sassrc ├── esbuild.js ├── index.tmpl.html ├── package.json ├── public │ └── img │ │ └── reaktor-logo.jpg ├── src │ ├── app.scss │ ├── board-navigation.ts │ ├── board │ │ ├── BoardView.tsx │ │ ├── BoardViewMessage.tsx │ │ ├── CollaborativeTextView.tsx │ │ ├── ConnectionsView.tsx │ │ ├── CursorsView.tsx │ │ ├── DragBorder.tsx │ │ ├── ImageView.tsx │ │ ├── ItemView.tsx │ │ ├── RectangularDragSelection.tsx │ │ ├── SaveAsTemplate.tsx │ │ ├── SelectionBorder.tsx │ │ ├── TextView.tsx │ │ ├── VideoView.tsx │ │ ├── autoFontSize.ts │ │ ├── board-coordinates.ts │ │ ├── board-drag.ts │ │ ├── board-focus.ts │ │ ├── board-permissions.ts │ │ ├── board-scroll-and-zoom.ts │ │ ├── boardContentArea.ts │ │ ├── contextmenu │ │ │ ├── ContextMenuView.tsx │ │ │ ├── alignments.tsx │ │ │ ├── areaTiling.tsx │ │ │ ├── colors.tsx │ │ │ ├── colorsAndShapes.tsx │ │ │ ├── connection-ends.tsx │ │ │ ├── fontSizes.tsx │ │ │ ├── hideContents.tsx │ │ │ ├── lock.tsx │ │ │ ├── shapes.tsx │ │ │ ├── textAlignments.tsx │ │ │ └── textFormats.tsx │ │ ├── contrasting-color.ts │ │ ├── double-click.ts │ │ ├── header │ │ │ ├── BoardViewHeader.tsx │ │ │ ├── OtherUsersView.tsx │ │ │ ├── SharingModalDialog.tsx │ │ │ ├── UserInfoModal.tsx │ │ │ └── UserInfoView.tsx │ │ ├── image-upload.ts │ │ ├── item-connect.ts │ │ ├── item-create.ts │ │ ├── item-cut-copy-paste.ts │ │ ├── item-delete.ts │ │ ├── item-drag.ts │ │ ├── item-dragmove.ts │ │ ├── item-duplicate.ts │ │ ├── item-hide-contents.ts │ │ ├── item-move-with-arrow-keys.ts │ │ ├── item-organizer.test.ts │ │ ├── item-organizer.ts │ │ ├── item-packer.ts │ │ ├── item-select-all.ts │ │ ├── item-selection.ts │ │ ├── item-setcontainer.ts │ │ ├── item-undo-redo.ts │ │ ├── keyboard-shortcuts.ts │ │ ├── local-storage-atom.ts │ │ ├── quillClickableLink.ts │ │ ├── quillPasteLinkOverText.ts │ │ ├── synchronize-focus-with-server.ts │ │ ├── tool-selection.ts │ │ ├── toolbars │ │ │ ├── BackToAllBoardsLink.tsx │ │ │ ├── BoardToolLayer.tsx │ │ │ ├── MainToolBar.tsx │ │ │ ├── MiniMapView.tsx │ │ │ ├── PaletteView.tsx │ │ │ ├── ToolSelector.tsx │ │ │ ├── UndoRedo.tsx │ │ │ └── ZoomControls.tsx │ │ ├── touchScreen.ts │ │ ├── zIndices.ts │ │ └── zoom-shortcuts.ts │ ├── components │ │ ├── BoardAccessPolicyEditor.tsx │ │ ├── BoardCrdtModeSelector.tsx │ │ ├── EditableSpan.tsx │ │ ├── HTMLEditableSpan.tsx │ │ ├── Icons.tsx │ │ ├── ModalContainer.tsx │ │ ├── UIColors.ts │ │ ├── browser.ts │ │ ├── components.tsx │ │ ├── onClickOutside.tsx │ │ └── sanitizeHTML.ts │ ├── dashboard │ │ ├── DashboardView.tsx │ │ └── bg.svg │ ├── embedding.tsx │ ├── google-auth.ts │ ├── img │ │ ├── checked-checkbox.png │ │ └── unchecked-checkbox.png │ ├── index.tsx │ ├── store │ │ ├── asset-store.ts │ │ ├── board-local-store.ts │ │ ├── board-store.test.ts │ │ ├── board-store.ts │ │ ├── crdt-store.ts │ │ ├── cursors-store.ts │ │ ├── recent-boards.ts │ │ ├── server-connection.ts │ │ └── user-session-store.ts │ └── style │ │ ├── board.scss │ │ ├── dashboard.scss │ │ ├── global.scss │ │ ├── header.scss │ │ ├── modal.scss │ │ ├── sharing-modal.scss │ │ ├── tool-layer.scss │ │ ├── user-info-modal.scss │ │ ├── utils.scss │ │ └── variables.scss └── tsconfig.json ├── integration └── src │ └── compact-history.test.ts ├── keycloak ├── README.md └── keycloak-db.dump ├── lint-staged.config.js ├── package.json ├── perf-tester ├── README.md ├── package.json ├── src │ ├── create-boards.ts │ └── index.ts └── tsconfig.json ├── playwright.config.ts ├── playwright └── src │ ├── pages │ ├── BoardApi.ts │ ├── BoardPage.ts │ └── DashboardPage.ts │ └── tests │ ├── accessPolicy.spec.ts │ ├── api.spec.ts │ ├── board.spec.ts │ ├── collaboration.spec.ts │ ├── dashboard.spec.ts │ └── navigation.spec.ts ├── scripts ├── migrate_user_email.sh └── run_dockerized.sh ├── state-management.md ├── tsconfig.json ├── vitest.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | backend/dist 2 | backend/localfiles 3 | Dockerfile 4 | frontend/.cache 5 | ignore 6 | latest.dump* 7 | node_modules 8 | npm-debug.log 9 | **/node_modules 10 | **/.env 11 | **/yarn-error.log 12 | **/.DS_Store -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = true 9 | 10 | [package.json] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a bug report to help us out 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | # Feature request story or issue 10 | 11 | ## Screenshot 12 | 13 | YOUR SCREENSHOT HERE: 14 | 15 | ## Url 16 | 17 | - Url where you took the screenshot: 18 | - URL: 19 | 20 | ## A sentence description of the problem 21 | 22 | Short DESCRIPTION: 23 | 24 | That's it! Thanks. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request]" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? There is a Please describe.** 10 | A clear and concise description of what the problem is. Ex. When I want to do X, I am always missing Y to make it easier [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Take a screenshot of the area you're working in** 16 | Even if the feature request doesn't exist there yet, what area would the feature request help within? 17 | SCREENSHOT: 18 | 19 | URL (of where you're working): 20 | 21 | **Describe alternatives you've considered** 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | **Additional context** 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | services: 7 | postgres: 8 | image: postgres:12 9 | env: 10 | POSTGRES_USER: r-board 11 | POSTGRES_PASSWORD: secret 12 | ports: 13 | - 13338:5432 14 | # Set health checks to wait until postgres has started 15 | options: >- 16 | --health-cmd pg_isready 17 | --health-interval 10s 18 | --health-timeout 5s 19 | --health-retries 5 20 | strategy: 21 | matrix: 22 | node-version: [18.x] 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - name: Install deps 30 | run: yarn 31 | - name: Run unit tests 32 | run: yarn test:unit 33 | - name: Wait for DB 34 | run: yarn wait-for-db 35 | - name: Run integration tests 36 | run: yarn test:integration 37 | - name: Build 38 | run: yarn build 39 | - name: Start server 40 | run: yarn start& 41 | env: 42 | SESSION_SIGNING_SECRET: notsosecretthing 43 | - name: Prepare Playwright 44 | run: npx playwright install chromium firefox 45 | - name: Run playwright tests 46 | run: yarn test:playwright 47 | - name: Archive results 48 | if: always() 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: test-results 52 | path: | 53 | playwright/results 54 | lint: 55 | runs-on: ubuntu-latest 56 | strategy: 57 | matrix: 58 | node-version: [18.x] 59 | steps: 60 | - uses: actions/checkout@v2 61 | - name: Use Node.js ${{ matrix.node-version }} 62 | uses: actions/setup-node@v1 63 | with: 64 | node-version: ${{ matrix.node-version }} 65 | - name: Install dependencies 66 | run: yarn install --frozen-lockfile 67 | - name: Check code formatting & generated files 68 | run: yarn lint 69 | docker-image: 70 | needs: test 71 | runs-on: ubuntu-latest 72 | if: github.ref == 'refs/heads/master' 73 | steps: 74 | - uses: actions/checkout@v2 75 | - name: docker login 76 | env: 77 | DOCKER_HUB_USER: ${{secrets.DOCKER_HUB_USER}} 78 | DOCKER_HUB_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}} 79 | run: docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 80 | - name: docker build 81 | run: docker build . -t raimohanska/ourboard:latest -t raimohanska/ourboard:${{github.sha}} 82 | - name: docker push 83 | run: docker push raimohanska/ourboard:latest && docker push raimohanska/ourboard:${{github.sha}} 84 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | node_modules/ 3 | dist/ 4 | yarn-error.log 5 | .env 6 | *.mp4 7 | backend/localfiles/ 8 | /ignore 9 | .DS_Store 10 | latest.dump 11 | .vscode 12 | backups 13 | playwright/results 14 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | "pre-commit": "lint-staged", 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | .cache/ 3 | node_modules/ 4 | dist/ 5 | yarn-error.log 6 | .env 7 | *.mp4 8 | backend/localfiles/ 9 | ignore/ 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: false, 4 | trailingComma: "all", 5 | } 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 as builder 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | COPY package.json yarn.lock ./ 7 | COPY frontend/package.json ./frontend/ 8 | COPY backend/package.json ./backend/ 9 | COPY perf-tester/package.json ./perf-tester/ 10 | 11 | RUN yarn install --frozen-lockfile --non-interactive 12 | 13 | COPY backend ./backend 14 | COPY frontend ./frontend 15 | COPY common ./common 16 | COPY perf-tester ./perf-tester 17 | COPY tsconfig.json . 18 | 19 | run yarn build 20 | 21 | FROM node:18 as runner 22 | 23 | COPY --from=builder /usr/src/app/backend/dist/index.js /usr/src/app/backend/dist/index.js 24 | COPY --from=builder /usr/src/app/backend/migrations /usr/src/app/backend/migrations 25 | COPY --from=builder /usr/src/app/frontend/public /usr/src/app/frontend/public 26 | COPY --from=builder /usr/src/app/frontend/dist /usr/src/app/frontend/dist 27 | WORKDIR /usr/src/app 28 | EXPOSE 1337 29 | 30 | WORKDIR /usr/src/app/backend 31 | CMD [ "node", "dist/index.js" ] -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | This project is licensed under the MIT license. 2 | Copyrights are respective of each contributor listed at the beginning of each definition file. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /backend/migrations/001_init.js: -------------------------------------------------------------------------------- 1 | exports.up = (pgm) => { 2 | pgm.sql(` 3 | CREATE TABLE IF NOT EXISTS board (id text PRIMARY KEY, name text NOT NULL); 4 | ALTER TABLE board ADD COLUMN IF NOT EXISTS content JSONB NOT NULL; 5 | ALTER TABLE board ADD COLUMN IF NOT EXISTS history JSONB NOT NULL default '[]'; 6 | CREATE TABLE IF NOT EXISTS board_event (board_id text REFERENCES board(id), last_serial integer, events JSONB NOT NULL, PRIMARY KEY (board_id, last_serial)); 7 | ALTER TABLE board_event ALTER COLUMN last_serial SET NOT NULL; 8 | ALTER TABLE board_event ALTER COLUMN board_id SET NOT NULL; 9 | CREATE TABLE IF NOT EXISTS board_api_token (board_id text REFERENCES board(id), token TEXT NOT NULL); 10 | CREATE TABLE IF NOT EXISTS app_user (id text PRIMARY KEY, email text NOT NULL); 11 | CREATE TABLE IF NOT EXISTS user_board (user_id text REFERENCES app_user(id), board_id text REFERENCES board(id), last_opened TIMESTAMP NOT NULL, PRIMARY KEY (user_id, board_id)); 12 | ALTER TABLE board ADD COLUMN IF NOT EXISTS ws_host TEXT NULL; 13 | ALTER TABLE board ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NULL DEFAULT now(); 14 | ALTER TABLE board_event ADD COLUMN IF NOT EXISTS saved_at TIMESTAMP NULL DEFAULT now(); 15 | `) 16 | } 17 | -------------------------------------------------------------------------------- /backend/migrations/002_add_first_serial.js: -------------------------------------------------------------------------------- 1 | exports.up = (pgm) => { 2 | pgm.sql(` 3 | ALTER TABLE board_event ADD COLUMN IF NOT EXISTS first_serial int; 4 | UPDATE board_event SET first_serial = COALESCE(CAST(events#>'{events, 0, serial}' as int), 0); 5 | ALTER TABLE board_event ALTER COLUMN first_serial SET NOT NULL; 6 | `) 7 | } 8 | -------------------------------------------------------------------------------- /backend/migrations/003_refactor_access.sql: -------------------------------------------------------------------------------- 1 | alter table board add public_read boolean null; 2 | alter table board add public_write boolean null; 3 | create table board_access ( 4 | board_id text not null references board(id), 5 | domain text null, 6 | email text null, 7 | access text null 8 | ); 9 | 10 | with allow_json as ( 11 | select id, jsonb_array_elements(content -> 'accessPolicy' -> 'allowList') as e 12 | from board), 13 | 14 | allow_entry as ( 15 | select id, e ->> 'domain' as domain, e ->> 'email' as email, e ->> 'access' as access 16 | from allow_json 17 | ) 18 | 19 | insert into board_access(board_id, domain, email, access) ( 20 | select ae.id as board_id, ae.domain, ae.email, ae.access 21 | from allow_entry ae 22 | ); 23 | 24 | update board 25 | set public_read = coalesce(content -> 'accessPolicy' ->> 'publicRead', 'false') :: boolean, 26 | public_write = coalesce(content -> 'accessPolicy' ->> 'publicWrite', 'false') :: boolean 27 | where content -> 'accessPolicy' is not null -------------------------------------------------------------------------------- /backend/migrations/004_public_boards_access.sql: -------------------------------------------------------------------------------- 1 | update board 2 | set public_read = 't', 3 | public_write = 't' 4 | where content -> 'accessPolicy' is null -------------------------------------------------------------------------------- /backend/migrations/005_uuid_extension.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -------------------------------------------------------------------------------- /backend/migrations/006_unique_email.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email); -------------------------------------------------------------------------------- /backend/migrations/007_add_crdt_update.sql: -------------------------------------------------------------------------------- 1 | alter table board_event add column crdt_update bytea null; -------------------------------------------------------------------------------- /backend/migrations/008_allow_crdt_only_bundle.sql: -------------------------------------------------------------------------------- 1 | alter table board_event alter column first_serial drop not null; 2 | alter table board_event add constraint first_serial_or_crdt check (crdt_update is not null OR first_serial is not null); -------------------------------------------------------------------------------- /backend/migrations/009_drop_board_event_primary_key.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE board_event DROP CONSTRAINT board_event_pkey; 2 | CREATE INDEX board_event_board_index ON board_event (board_id); -------------------------------------------------------------------------------- /backend/migrations/010_drop_history_column.sql: -------------------------------------------------------------------------------- 1 | alter table board drop column history; -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rboard-backend", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@types/cookies": "^0.7.7", 8 | "@types/date-fns": "^2.6.0", 9 | "@types/express-ws": "^3.0.0", 10 | "@types/html-entities": "^1.2.16", 11 | "@types/json-diff": "^0.5.0", 12 | "@types/lodash": "^4.14.161", 13 | "@types/node": "^14.6.2", 14 | "@types/pg": "^7.14.4", 15 | "@types/ramda": "^0.27.29", 16 | "@types/tcp-port-used": "^1.0.0", 17 | "@types/ws": "^7.4.0", 18 | "aws-sdk": "^2.778.0", 19 | "cookies": "^0.8.0", 20 | "csv-writer": "^1.6.0", 21 | "date-fns": "^2.17.0", 22 | "dotenv": "^8.2.0", 23 | "express": "^4.17.1", 24 | "express-ws": "^4.0.0", 25 | "fp-ts": "^2.9.5", 26 | "google-auth-library": "^7.0.2", 27 | "googleapis": "^110.0.0", 28 | "html-entities": "^2.1.0", 29 | "io-ts": "^2.2.15", 30 | "io-ts-types": "^0.5.15", 31 | "json-diff": "^0.5.4", 32 | "jsonwebtoken": "^8.5.1", 33 | "lodash": "^4.17.20", 34 | "lonna": "^0.12.2", 35 | "monocle-ts": "^2.3.7", 36 | "newtype-ts": "^0.3.4", 37 | "node-pg-migrate": "^6.0.0", 38 | "openapi-types": "^7.2.3", 39 | "pg": "^8.3.3", 40 | "pg-query-stream": "^4.2.1", 41 | "swagger-ui-express": "^4.1.6", 42 | "tcp-port-used": "^1.0.2", 43 | "tsx": "3.13.0", 44 | "typera-express": "^2.3.0", 45 | "typera-openapi": "^0.11.0", 46 | "typescript": "^5.3", 47 | "uWebSockets.js": "uNetworking/uWebSockets.js#v18.14.0", 48 | "uuid": "^8.3.0", 49 | "uuid4": "^2.0.2", 50 | "y-protocols": "^1.0.6" 51 | }, 52 | "scripts": { 53 | "start": "node --enable-source-maps .", 54 | "dev": "npm-run-all --parallel watch apidocs:dev", 55 | "build": "tsc && ncc build dist/backend/src/server.js -o dist", 56 | "watch": "tsc-watch --onSuccess \"node --enable-source-maps dist/backend/src/server.js\" --preserveWatchOutput", 57 | "watch-ts": "tsc-watch --preserveWatchOutput", 58 | "apidocs": "typera-openapi --prettify src/api-routes.ts", 59 | "apidocs:dev": "nodemon --watch src/api-routes.ts --exec 'yarn apidocs'", 60 | "lint": "yarn apidocs --check" 61 | }, 62 | "engines": { 63 | "node": ">=14" 64 | }, 65 | "devDependencies": { 66 | "@types/express": "^4.17.7", 67 | "@types/jsonwebtoken": "^8.5.0", 68 | "@types/node-fetch": "^2.5.9", 69 | "@types/swagger-ui-express": "^4.1.2", 70 | "@types/uuid": "^8.3.0", 71 | "@vercel/ncc": "^0.38.1", 72 | "tsc-watch": "^4.2.9" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /backend/src/api/api-routes.ts: -------------------------------------------------------------------------------- 1 | import { router } from "typera-express" 2 | import { boardCreate } from "./board-create" 3 | import { boardCSVGet } from "./board-csv-get" 4 | import { boardGet } from "./board-get" 5 | import { boardHierarchyGet } from "./board-hierarchy-get" 6 | import { boardHistoryGet } from "./board-history-get" 7 | import { boardUpdate } from "./board-update" 8 | import { githubWebhook } from "./github-webhook" 9 | import { itemCreate } from "./item-create" 10 | import { itemCreateOrUpdate } from "./item-create-or-update" 11 | 12 | export default router( 13 | boardGet, 14 | boardHierarchyGet, 15 | boardCSVGet, 16 | boardCreate, 17 | boardUpdate, 18 | githubWebhook, 19 | itemCreate, 20 | itemCreateOrUpdate, 21 | boardHistoryGet, 22 | ) 23 | -------------------------------------------------------------------------------- /backend/src/api/board-create.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { NonEmptyString } from "io-ts-types" 3 | import { ok } from "typera-common/response" 4 | import { body } from "typera-express/parser" 5 | import { 6 | Board, 7 | BoardAccessPolicyCodec, 8 | CrdtDisabled, 9 | CrdtEnabled, 10 | newBoard, 11 | optional, 12 | } from "../../../common/src/domain" 13 | import { addBoard } from "../board-state" 14 | import { route } from "./utils" 15 | import { getConfig } from "../config" 16 | /** 17 | * Creates a new board. 18 | * 19 | * @tags Board 20 | */ 21 | export const boardCreate = route 22 | .post("/api/v1/board") 23 | .use(body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec, crdt: optional(t.literal(true)) }))) 24 | .handler(async (request) => { 25 | const crdt = (getConfig().crdt === "true" || request.body.crdt) ?? false 26 | let board: Board = newBoard(request.body.name, crdt ? CrdtEnabled : CrdtDisabled, request.body.accessPolicy) 27 | const boardWithHistory = await addBoard(board, true) 28 | return ok({ id: boardWithHistory.board.id, accessToken: boardWithHistory.accessTokens[0] }) 29 | }) 30 | -------------------------------------------------------------------------------- /backend/src/api/board-csv-get.ts: -------------------------------------------------------------------------------- 1 | import { createArrayCsvStringifier } from "csv-writer" 2 | import _ from "lodash" 3 | import { ok } from "typera-common/response" 4 | import { Board, Container, Item, TextItem } from "../../../common/src/domain" 5 | import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" 6 | import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" 7 | import { yWebSocketServer } from "../board-yjs-server" 8 | 9 | /** 10 | * Gets board current contents 11 | * 12 | * @tags Board 13 | */ 14 | export const boardCSVGet = route 15 | .get("/api/v1/board/:boardId/csv") 16 | .use(apiTokenHeader) 17 | .handler((request) => 18 | checkBoardAPIAccess(request, async (boardState) => { 19 | const board = augmentBoardWithCRDT( 20 | await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), 21 | boardState.board, 22 | ) 23 | const textItemsWithParent = Object.values(board.items).filter( 24 | (i) => i.containerId !== undefined && (i.type === "text" || i.type === "note"), 25 | ) 26 | const textItemGroups = _.groupBy(textItemsWithParent, (i) => i.containerId) 27 | const rows = Object.entries(textItemGroups).map(([parentId, textItems]) => { 28 | const rowContainer = board.items[parentId] 29 | return { 30 | parents: parentChain(board)(rowContainer), 31 | rowContainer, 32 | textItems, 33 | } as Row 34 | }) 35 | if (rows.length === 0) return csv(board, []) 36 | const maxDepth = _.max(rows.map((r) => r.parents.length))! 37 | const csvData = rows.map((r) => { 38 | return [ 39 | ...r.parents.map((c) => c.text), 40 | ..._.times(maxDepth - r.parents.length, () => ""), 41 | r.rowContainer.text, 42 | ...r.textItems.map((i) => i.text), 43 | ] 44 | }) 45 | return csv(board, csvData) 46 | }), 47 | ) 48 | 49 | type Row = { parents: Container[]; rowContainer: Container; textItems: TextItem[] } 50 | 51 | const parentChain = (board: Board) => (item: Item): Container[] => { 52 | if (!item.containerId) return [] 53 | const parent = board.items[item.containerId] 54 | if (parent.type !== "container") 55 | throw Error(`Parent item ${item.containerId} is of type ${parent.type}, expecting container`) 56 | return [parent, ...parentChain(board)(parent)] 57 | } 58 | 59 | function csv(board: Board, rows: string[][]) { 60 | const result = createArrayCsvStringifier({}).stringifyRecords(rows) 61 | return ok(result, { "content-type": "text/csv", "content-disposition": `attachment; filename=${board.name}.csv` }) 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/api/board-get.ts: -------------------------------------------------------------------------------- 1 | import { ok } from "typera-common/response" 2 | import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" 3 | import { yWebSocketServer } from "../board-yjs-server" 4 | import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" 5 | 6 | /** 7 | * Gets board current contents 8 | * 9 | * @tags Board 10 | */ 11 | export const boardGet = route 12 | .get("/api/v1/board/:boardId") 13 | .use(apiTokenHeader) 14 | .handler((request) => 15 | checkBoardAPIAccess(request, async (boardState) => { 16 | const board = augmentBoardWithCRDT( 17 | await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), 18 | boardState.board, 19 | ) 20 | return ok({ board }) 21 | }), 22 | ) 23 | -------------------------------------------------------------------------------- /backend/src/api/board-hierarchy-get.ts: -------------------------------------------------------------------------------- 1 | import { ok } from "typera-common/response" 2 | import { Board, Item } from "../../../common/src/domain" 3 | import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" 4 | import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" 5 | import { yWebSocketServer } from "../board-yjs-server" 6 | 7 | /** 8 | * Gets board current contents 9 | * 10 | * @tags Board 11 | */ 12 | export const boardHierarchyGet = route 13 | .get("/api/v1/board/:boardId/hierarchy") 14 | .use(apiTokenHeader) 15 | .handler((request) => 16 | checkBoardAPIAccess(request, async (boardState) => { 17 | const board = augmentBoardWithCRDT( 18 | await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), 19 | boardState.board, 20 | ) 21 | return ok({ board: getBoardHierarchy(board) }) 22 | }), 23 | ) 24 | 25 | export type ItemHierarchy = Item & { children: ItemHierarchy[] } 26 | export function getBoardHierarchy(board: Board) { 27 | const allItems = Object.values(board.items) 28 | const rootItems = allItems.filter((i) => i.containerId === undefined).map(getItemHierarchy(allItems)) 29 | return { ...board, items: rootItems } 30 | } 31 | const getItemHierarchy = (items: Item[]) => (item: Item): ItemHierarchy => { 32 | const children: ItemHierarchy[] = items.filter((i) => i.containerId === item.id).map(getItemHierarchy(items)) 33 | return { ...item, children } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/api/board-history-get.ts: -------------------------------------------------------------------------------- 1 | import { ok, streamingBody } from "typera-common/response" 2 | import { getFullBoardHistory } from "../board-store" 3 | import { withDBClient } from "../db" 4 | import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" 5 | 6 | /** 7 | * List the history of a board 8 | * 9 | * @tags Board 10 | */ 11 | export const boardHistoryGet = route 12 | .get("/api/v1/board/:boardId/history") 13 | .use(apiTokenHeader) 14 | .handler((request) => 15 | checkBoardAPIAccess(request, async (board) => { 16 | return ok( 17 | streamingJSONBody("history", async (callback) => { 18 | await withDBClient( 19 | async (client) => 20 | await getFullBoardHistory(board.board.id, client, (bundle) => bundle.forEach(callback)), 21 | ) 22 | }), 23 | ) 24 | }), 25 | ) 26 | 27 | function streamingJSONBody(fieldName: string, generator: (callback: (item: any) => void) => Promise) { 28 | return streamingBody(async (stream) => { 29 | // Due to memory concerns we fetch board histories from DB as chunks, so this API 30 | // response must also be chunked 31 | try { 32 | stream.write(`{"${fieldName}":[`) 33 | let chunksProcessed = 0 34 | await generator((item) => { 35 | let prefix = chunksProcessed === 0 ? "" : "," 36 | stream.write(`${prefix}${JSON.stringify(item)}`) 37 | chunksProcessed++ 38 | }) 39 | stream.write("]}") 40 | stream.end() 41 | //console.log(`Wrote ${chunksProcessed} chunks`) 42 | } catch (e) { 43 | console.error(`Error writing a streamed body: ${e}`) 44 | stream.end() 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /backend/src/api/board-update.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { NonEmptyString } from "io-ts-types" 3 | import { ok } from "typera-common/response" 4 | import { body } from "typera-express/parser" 5 | import { BoardAccessPolicyCodec } from "../../../common/src/domain" 6 | import { apiTokenHeader, checkBoardAPIAccess, dispatchSystemAppEvent, route } from "./utils" 7 | import { renameBoardConvenienceColumnOnly, updateBoardAccessPolicy } from "../board-store" 8 | 9 | /** 10 | * Changes board name and, optionally, access policy. 11 | * 12 | * @tags Board 13 | */ 14 | export const boardUpdate = route 15 | .put("/api/v1/board/:boardId") 16 | .use(apiTokenHeader, body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec }))) 17 | .handler((request) => 18 | checkBoardAPIAccess(request, async (board) => { 19 | const { boardId } = request.routeParams 20 | const { name, accessPolicy } = request.body 21 | await renameBoardConvenienceColumnOnly(boardId, name) 22 | dispatchSystemAppEvent(board, { action: "board.rename", boardId, name }) 23 | if (accessPolicy) { 24 | await updateBoardAccessPolicy(boardId, accessPolicy) 25 | dispatchSystemAppEvent(board, { action: "board.setAccessPolicy", boardId, accessPolicy }) 26 | } 27 | return ok({ ok: true }) 28 | }), 29 | ) 30 | -------------------------------------------------------------------------------- /backend/src/api/github-webhook.ts: -------------------------------------------------------------------------------- 1 | import { encode as htmlEncode } from "html-entities" 2 | import * as t from "io-ts" 3 | import { badRequest, internalServerError, ok } from "typera-common/response" 4 | import { body } from "typera-express/parser" 5 | import { RED, YELLOW } from "../../../common/src/colors" 6 | import { Note } from "../../../common/src/domain" 7 | import { getBoard } from "../board-state" 8 | import { addItem, dispatchSystemAppEvent, InvalidRequest, route } from "./utils" 9 | 10 | // TODO: require API_TOKEN header for github too! 11 | /** 12 | * GitHub webhook 13 | * 14 | * @tags Webhooks 15 | */ 16 | export const githubWebhook = route 17 | .post("/api/v1/webhook/github/:boardId") 18 | .use( 19 | body( 20 | t.partial({ 21 | issue: t.type({ 22 | html_url: t.string, 23 | title: t.string, 24 | number: t.number, 25 | state: t.string, 26 | labels: t.array(t.type({ name: t.string })), 27 | }), 28 | }), 29 | ), 30 | ) 31 | .handler(async (request) => { 32 | try { 33 | const boardId = request.routeParams.boardId 34 | const body = request.body 35 | const board = await getBoard(boardId) 36 | if (!board) { 37 | console.warn(`Github webhook call for unknown board ${boardId}`) 38 | return ok() 39 | } 40 | if (body.issue) { 41 | const url = body.issue.html_url 42 | const title = body.issue.title 43 | const number = body.issue.number.toString() 44 | const state = body.issue.state 45 | if (state !== "open") { 46 | console.log(`Github webhook call board ${boardId}: Item in ${state} state`) 47 | } else { 48 | const linkStart = `` 49 | const linkHTML = `${linkStart}${htmlEncode(number)} ${htmlEncode(title)}` 50 | const existingItem = Object.values(board.board.items).find( 51 | (i) => i.type === "note" && i.text.includes(url), 52 | ) as Note | undefined 53 | const isBug = body.issue.labels.some((l) => l.name === "bug") 54 | const color = isBug ? RED : YELLOW 55 | if (!existingItem) { 56 | console.log(`Github webhook call board ${boardId}: New item`) 57 | addItem(board, "note", linkHTML, color, "New issues") 58 | } else { 59 | console.log(`Github webhook call board ${boardId}: Item exists`) 60 | const updatedItem: Note = { ...existingItem, color } 61 | dispatchSystemAppEvent(board, { action: "item.update", boardId, items: [updatedItem] }) 62 | } 63 | } 64 | } 65 | return ok() 66 | } catch (e) { 67 | console.error(e) 68 | if (e instanceof InvalidRequest) { 69 | return badRequest(e.message) 70 | } else { 71 | return internalServerError() 72 | } 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /backend/src/api/item-create.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { ok } from "typera-common/response" 3 | import { body } from "typera-express/parser" 4 | import { addItem, apiTokenHeader, checkBoardAPIAccess, route } from "./utils" 5 | 6 | /** 7 | * Creates a new item on given board. If you want to add the item onto a 8 | * specific area/container element on the board, you can find the id of the 9 | * container by inspecting with your browser. 10 | * 11 | * @tags Board 12 | */ 13 | export const itemCreate = route 14 | .post("/api/v1/board/:boardId/item") 15 | .use( 16 | apiTokenHeader, 17 | body( 18 | t.intersection([ 19 | t.type({ 20 | type: t.literal("note"), 21 | text: t.string, 22 | color: t.string, 23 | }), 24 | t.partial({ 25 | container: t.string, 26 | x: t.number, 27 | y: t.number, 28 | width: t.number, 29 | height: t.number, 30 | }), 31 | ]), 32 | ), 33 | ) 34 | .handler((request) => 35 | checkBoardAPIAccess(request, async (board) => { 36 | const { type, text, color, container, ...rest } = request.body 37 | console.log(`POST item for board ${board.board.id}: ${JSON.stringify(request.req.body)}`) 38 | const item = addItem(board, type, text, color, container, undefined, rest) 39 | return ok(item) 40 | }), 41 | ) 42 | -------------------------------------------------------------------------------- /backend/src/board-state.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | describe("board state iteration", () => { 4 | it("is safe", () => { 5 | // Checking that map value iteration is safe when deleteting items on the way 6 | const boards = new Map() 7 | boards.set(1, 1) 8 | boards.set(2, 2) 9 | const results: number[] = [] 10 | for (let b of boards.values()) { 11 | results.push(b) 12 | boards.delete(b) 13 | } 14 | expect(results).toEqual([1, 2]) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /backend/src/config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import fs from "fs" 3 | import { authProvider } from "./oauth" 4 | import * as t from "io-ts" 5 | import { optional } from "../../common/src/domain" 6 | import { decodeOrThrow } from "./decodeOrThrow" 7 | 8 | export type StorageBackend = Readonly< 9 | { type: "LOCAL"; directory: string; assetStorageURL: string } | { type: "AWS"; assetStorageURL: string } 10 | > 11 | export type Config = Readonly<{ storageBackend: StorageBackend; authSupported: boolean; crdt: CrdtConfigString }> 12 | 13 | const CrdtConfigString = t.union([ 14 | t.literal("opt-in"), 15 | t.literal("opt-in-authenticated"), 16 | t.literal("true"), 17 | t.literal("false"), 18 | ]) 19 | export type CrdtConfigString = t.TypeOf 20 | 21 | export const getConfig = (): Config => { 22 | const storageBackend: StorageBackend = process.env.AWS_ASSETS_BUCKET_URL 23 | ? { type: "AWS", assetStorageURL: process.env.AWS_ASSETS_BUCKET_URL } 24 | : { type: "LOCAL", directory: path.resolve("localfiles"), assetStorageURL: "/assets" } 25 | 26 | if (storageBackend.type === "LOCAL") { 27 | try { 28 | fs.mkdirSync(storageBackend.directory) 29 | } catch (e) {} 30 | } 31 | 32 | const crdt = decodeOrThrow(CrdtConfigString, process.env.COLLABORATIVE_EDITING ?? "opt-in") 33 | 34 | return { 35 | storageBackend, 36 | authSupported: authProvider !== null, 37 | crdt, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/connection-handler.ts: -------------------------------------------------------------------------------- 1 | import { AppEvent, Id, Serial, EventWrapper } from "../../common/src/domain" 2 | import { getActiveBoards } from "./board-state" 3 | import { getConfig } from "./config" 4 | import { releaseLocksFor } from "./locker" 5 | import { broadcastCursorPositions, endSession, startSession } from "./websocket-sessions" 6 | import { WsWrapper, toBuffer } from "./ws-wrapper" 7 | 8 | export type ConnectionHandlerParams = Readonly<{ 9 | getSignedPutUrl: (key: string) => string 10 | }> 11 | 12 | export const connectionHandler = (socket: WsWrapper, handleMessage: MessageHandler) => { 13 | startSession(socket) 14 | const config = getConfig() 15 | socket.send( 16 | toBuffer({ 17 | action: "server.config", 18 | assetStorageURL: config.storageBackend.assetStorageURL, 19 | authSupported: config.authSupported, 20 | crdt: config.crdt, 21 | }), 22 | ) 23 | socket.onError(() => { 24 | socket.close() 25 | }) 26 | socket.onMessage(async (o: object) => { 27 | try { 28 | let event = o as EventWrapper 29 | let serialsToAck: Record = {} 30 | for (const e of event.events) { 31 | const serialAck = await handleMessage(socket, e) 32 | if (serialAck === true) { 33 | } else if (serialAck === false) { 34 | console.warn("Unhandled app-event message", e) 35 | } else { 36 | serialsToAck[serialAck.boardId] = serialAck.serial 37 | } 38 | } 39 | if (event.ackId) { 40 | socket.send(toBuffer({ action: "ack", ackId: event.ackId, serials: serialsToAck })) 41 | } 42 | } catch (e) { 43 | console.error("Error while handling event from client. Closing connection.", e) 44 | socket.close() 45 | } 46 | }) 47 | 48 | socket.onClose(() => { 49 | endSession(socket) 50 | getActiveBoards().forEach((state) => { 51 | delete state.cursorPositions[socket.id] 52 | state.cursorsMoved = true 53 | }) 54 | releaseLocksFor(socket) 55 | }) 56 | } 57 | 58 | setInterval(() => { 59 | getActiveBoards().forEach((bh) => { 60 | if (bh.cursorsMoved) { 61 | broadcastCursorPositions(bh.board.id, bh.cursorPositions) 62 | bh.cursorsMoved = false 63 | } 64 | }) 65 | }, 100) 66 | 67 | export type MessageHandler = (socket: WsWrapper, appEvent: AppEvent) => Promise 68 | export type MessageHandlerResult = { boardId: Id; serial: Serial } | boolean 69 | -------------------------------------------------------------------------------- /backend/src/db.ts: -------------------------------------------------------------------------------- 1 | import pg, { PoolClient } from "pg" 2 | import process from "process" 3 | import migrate from "node-pg-migrate" 4 | 5 | const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://r-board:secret@127.0.0.1:13338/r-board" 6 | const DATABASE_SSL_ENABLED = process.env.DATABASE_SSL_ENABLED === "true" 7 | 8 | const pgConfig = { 9 | connectionString: DATABASE_URL, 10 | ssl: DATABASE_SSL_ENABLED 11 | ? { 12 | rejectUnauthorized: false, 13 | } 14 | : undefined, 15 | } 16 | const connectionPool = new pg.Pool(pgConfig) 17 | 18 | export function closeConnectionPool() { 19 | connectionPool.end() 20 | } 21 | 22 | export async function initDB(backendDir: string = ".") { 23 | console.log("Running database migrations") 24 | await inTransaction((client) => 25 | migrate({ 26 | count: 100000, 27 | databaseUrl: DATABASE_URL, 28 | migrationsTable: "pgmigrations", 29 | dir: `${backendDir}/migrations`, 30 | direction: "up", 31 | dbClient: client, 32 | }), 33 | ) 34 | console.log("Completed database migrations") 35 | 36 | return { 37 | onEvent: async (eventNames: string[], cb: (n: pg.Notification) => any) => { 38 | const client = await connectionPool.connect() 39 | eventNames.map((e) => client.query(`LISTEN ${e}`)) 40 | client.on("notification", cb) 41 | }, 42 | } 43 | } 44 | 45 | export async function withDBClient(f: (client: PoolClient) => Promise): Promise { 46 | const client = await connectionPool.connect() 47 | try { 48 | await client.query("BEGIN;SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;") 49 | return await f(client) 50 | } finally { 51 | await client.query("ROLLBACK;") 52 | client.release() 53 | } 54 | } 55 | 56 | export async function inTransaction(f: (client: PoolClient) => Promise): Promise { 57 | const client = await connectionPool.connect() 58 | try { 59 | await client.query(` 60 | BEGIN; 61 | SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE; 62 | `) 63 | const result = await f(client) 64 | await client.query("COMMIT;") 65 | return result 66 | } catch (e) { 67 | await client.query("ROLLBACK;") 68 | throw e 69 | } finally { 70 | client.release() 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /backend/src/decodeOrThrow.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts" 2 | import { Left, isLeft, left } from "fp-ts/lib/Either" 3 | import { PathReporter } from "io-ts/lib/PathReporter" 4 | 5 | export function decodeOrThrow(codec: t.Type, input: any): T { 6 | const validationResult = codec.decode(input) 7 | if (isLeft(validationResult)) { 8 | throw new ValidationError(validationResult) 9 | } 10 | return validationResult.right 11 | } 12 | 13 | class ValidationError extends Error { 14 | constructor(errors: Left) { 15 | super(report_(errors.left)) 16 | } 17 | } 18 | 19 | function report_(errors: t.Errors) { 20 | return PathReporter.report(left(errors)).join("\n") 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/env.ts: -------------------------------------------------------------------------------- 1 | import process from "process" 2 | 3 | export function getEnv(name: string): string { 4 | const value = process.env[name] 5 | if (!value) throw new Error("Missing ENV: " + name) 6 | return value 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/expiring-map.ts: -------------------------------------------------------------------------------- 1 | export function AutoExpiringMap(ttlSeconds: number) { 2 | const timers = new Map() 3 | const data: Record = {} 4 | const listeners: ((v: Record) => any)[] = [] 5 | const proxy = new Proxy(data, { 6 | set(target, key, value) { 7 | if (typeof key === "symbol") return false 8 | target[key] = value 9 | setExpiryTimer(key) 10 | listeners.forEach((l) => l(target)) 11 | return true 12 | }, 13 | deleteProperty(target, key) { 14 | if (typeof key === "symbol") return false 15 | const didDelete = delete target[key] 16 | if (!didDelete) { 17 | return false 18 | } 19 | listeners.forEach((l) => l(target)) 20 | return true 21 | }, 22 | }) 23 | 24 | const setExpiryTimer = (key: string | number) => { 25 | if (timers.has(key)) { 26 | clearTimeout(timers.get(key)!) 27 | } 28 | 29 | timers.set( 30 | key, 31 | setTimeout(() => { 32 | timers.delete(key) 33 | delete proxy[key] 34 | }, ttlSeconds * 1000), 35 | ) 36 | } 37 | 38 | const autoExpiringMap = { 39 | get: (key: string) => proxy[key], 40 | has: (key: string) => !!proxy[key], 41 | entries: () => Object.entries(proxy), 42 | delete: (key: string) => delete proxy[key], 43 | set: (key: string, value: any) => { 44 | proxy[key] = value 45 | }, 46 | onChange: (fn: (v: Record) => any) => { 47 | listeners.push(fn) 48 | return autoExpiringMap 49 | }, 50 | } 51 | 52 | return autoExpiringMap 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/google-auth.ts: -------------------------------------------------------------------------------- 1 | import { google } from "googleapis" 2 | import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" 3 | import { assertNotNull } from "../../common/src/assertNotNull" 4 | import { getEnv } from "./env" 5 | import { AuthProvider } from "./oauth" 6 | import { ROOT_URL } from "./host-config" 7 | import { decodeOrThrow } from "./decodeOrThrow" 8 | import * as T from "io-ts" 9 | import { optional } from "../../common/src/domain" 10 | import JWT from "jsonwebtoken" 11 | 12 | type GoogleConfig = { 13 | clientID: string 14 | clientSecret: string 15 | callbackURL: string 16 | } 17 | 18 | export const googleConfig: GoogleConfig | null = process.env.GOOGLE_OAUTH_CLIENT_ID 19 | ? { 20 | clientID: getEnv("GOOGLE_OAUTH_CLIENT_ID"), 21 | clientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET"), 22 | callbackURL: `${ROOT_URL}/google-callback`, 23 | } 24 | : null 25 | 26 | export const GoogleAuthProvider = (googleConfig: GoogleConfig): AuthProvider => { 27 | console.log(`Setting up Google authentication using client ID ${googleConfig.clientID}`) 28 | 29 | const googleScopes = ["email", "https://www.googleapis.com/auth/userinfo.profile"] 30 | 31 | function googleOAUTH2() { 32 | if (!googleConfig.clientID || !googleConfig.clientSecret) 33 | throw new Error("Missing environment variables for Google OAuth") 34 | return new google.auth.OAuth2(googleConfig.clientID, googleConfig.clientSecret, googleConfig.callbackURL) 35 | } 36 | 37 | async function getAuthPageURL() { 38 | return googleOAUTH2().generateAuthUrl({ 39 | scope: googleScopes, 40 | prompt: "select_account", 41 | }) 42 | } 43 | 44 | const IdToken = T.strict({ 45 | hd: optional(T.string), 46 | email: T.string, 47 | email_verified: T.boolean, 48 | name: T.string, 49 | picture: optional(T.string), 50 | }) 51 | 52 | async function getAccountFromCode(code: string): Promise { 53 | const auth = googleOAUTH2() 54 | const data = await auth.getToken(code) 55 | const idToken = decodeOrThrow(IdToken, JWT.decode(assertNotNull(data.tokens.id_token))) 56 | const email = idToken.email 57 | 58 | return { 59 | name: idToken.name, 60 | email, 61 | picture: idToken.picture ?? undefined, 62 | domain: idToken.hd ?? null, 63 | } 64 | } 65 | 66 | return { 67 | getAuthPageURL, 68 | getAccountFromCode, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/src/host-config.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_URL = process.env.ROOT_URL ?? "http://localhost:1337" 2 | export const ROOT_HOST = new URL(ROOT_URL).host 3 | export const ROOT_PROTOCOL = new URL(ROOT_URL).protocol 4 | export const WS_HOST_LOCAL = (process.env.WS_HOST_LOCAL ?? ROOT_HOST).split(",") 5 | export const WS_HOST_DEFAULT = process.env.WS_HOST_DEFAULT ?? ROOT_HOST 6 | export const WS_PROTOCOL = process.env.WS_PROTOCOL ?? (ROOT_PROTOCOL.startsWith("https") ? "wss" : "ws") 7 | -------------------------------------------------------------------------------- /backend/src/http-session.ts: -------------------------------------------------------------------------------- 1 | import Cookies from "cookies" 2 | import { IncomingMessage, ServerResponse } from "http" 3 | import JWT from "jsonwebtoken" 4 | import { getEnv } from "./env" 5 | import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" 6 | import { ISOTimeStamp, newISOTimeStamp } from "../../common/src/domain" 7 | 8 | const secret = getEnv("SESSION_SIGNING_SECRET") 9 | 10 | export type LoginInfo = OAuthAuthenticatedUser & { 11 | timestamp: ISOTimeStamp | undefined 12 | } 13 | 14 | export function getSessionIdFromCookies(req: IncomingMessage): string | null { 15 | return new Cookies(req, null as any).get("sessionId") ?? null 16 | } 17 | 18 | // Get / set authenticated user stored in cookies 19 | export function getAuthenticatedUser(req: IncomingMessage): LoginInfo | null { 20 | const userCookie = new Cookies(req, null as any).get("user") 21 | if (userCookie) { 22 | return getAuthenticatedUserFromJWT(userCookie) 23 | } 24 | return null 25 | } 26 | 27 | export function getAuthenticatedUserFromJWT(jwt: string): LoginInfo | null { 28 | try { 29 | JWT.verify(jwt, secret) 30 | const loginInfo = JWT.decode(jwt) as LoginInfo 31 | if (loginInfo.domain === undefined) { 32 | console.log("Rejecting legacy token without domain") 33 | return null 34 | } 35 | return loginInfo 36 | } catch (e) { 37 | console.warn("Token verification failed", jwt, e) 38 | } 39 | return null 40 | } 41 | 42 | export function setAuthenticatedUser(req: IncomingMessage, res: ServerResponse, userInfo: OAuthAuthenticatedUser) { 43 | const loginInfo: LoginInfo = { ...userInfo, timestamp: newISOTimeStamp() } 44 | const jwt = JWT.sign(loginInfo, secret) 45 | new Cookies(req, res).set("user", jwt, { 46 | maxAge: 365 * 24 * 3600 * 1000, 47 | httpOnly: false, 48 | }) // Max 365 days expiration 49 | } 50 | 51 | export function removeAuthenticatedUser(req: IncomingMessage, res: ServerResponse) { 52 | new Cookies(req, res).set("user", "", { maxAge: 0, httpOnly: true }) 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/locker.ts: -------------------------------------------------------------------------------- 1 | import { Id, BoardItemEvent, isPersistableBoardItemEvent, getItemIds, ItemLocks } from "../../common/src/domain" 2 | import { getActiveBoards, ServerSideBoardState } from "./board-state" 3 | import { AutoExpiringMap } from "./expiring-map" 4 | import { WsWrapper } from "./ws-wrapper" 5 | 6 | const LOCK_TTL_SECONDS = 10 7 | 8 | export function Locks(onChange: (locks: ItemLocks) => any) { 9 | const locks = AutoExpiringMap(LOCK_TTL_SECONDS).onChange(onChange) 10 | 11 | return { 12 | lockItem: (itemId: Id, sessionId: Id) => { 13 | if (locks.has(itemId) && locks.get(itemId) !== sessionId) { 14 | return false 15 | } 16 | 17 | locks.set(itemId, sessionId) 18 | return true 19 | }, 20 | unlockItem: (itemId: Id, sessionId: Id) => { 21 | if (locks.get(itemId) === sessionId) { 22 | return locks.delete(itemId) 23 | } 24 | 25 | return false 26 | }, 27 | delete: (itemId: string) => locks.delete(itemId), 28 | entries: () => locks.entries(), 29 | } 30 | } 31 | 32 | export function obtainLock(locks: ServerSideBoardState["locks"], e: BoardItemEvent, socket: WsWrapper) { 33 | if (isPersistableBoardItemEvent(e)) { 34 | const itemIds = getItemIds(e) 35 | // Since we are operating on multiple items at a time, locking must succeed for all of them 36 | // for the action to succeed 37 | return itemIds.every((id) => locks.lockItem(id, socket.id)) 38 | } else { 39 | const { itemId, action } = e 40 | switch (action) { 41 | case "item.lock": 42 | return locks.lockItem(itemId, socket.id) 43 | case "item.unlock": 44 | return locks.unlockItem(itemId, socket.id) 45 | } 46 | } 47 | } 48 | 49 | export function releaseLocksFor(socket: WsWrapper) { 50 | getActiveBoards().forEach((state) => { 51 | const locks = state.locks 52 | for (const [itemId, sessionId] of locks.entries()) { 53 | if (socket.id === sessionId) { 54 | locks.delete(itemId) 55 | } 56 | } 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/oauth.ts: -------------------------------------------------------------------------------- 1 | import Cookies from "cookies" 2 | import { Express, Request, Response } from "express" 3 | import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" 4 | import { removeAuthenticatedUser, setAuthenticatedUser } from "./http-session" 5 | import { GoogleAuthProvider, googleConfig } from "./google-auth" 6 | import { GenericOIDCAuthProvider, genericOIDCConfig } from "./generic-oidc-auth" 7 | 8 | export interface AuthProvider { 9 | getAuthPageURL: () => Promise 10 | getAccountFromCode: (code: string) => Promise 11 | logout?: (req: Request, res: Response) => Promise 12 | } 13 | 14 | export function setupAuth(app: Express, provider: AuthProvider) { 15 | app.get("/login", async (req, res) => { 16 | new Cookies(req, res).set("returnTo", parseReturnPath(req), { 17 | maxAge: 24 * 3600 * 1000, 18 | httpOnly: true, 19 | }) // Max 24 hours 20 | const authUrl = await provider.getAuthPageURL() 21 | res.setHeader("content-type", "text/html") 22 | res.send(`Signing in...`) 23 | }) 24 | 25 | app.get("/logout", async (req, res) => { 26 | removeAuthenticatedUser(req, res) 27 | if (provider.logout) { 28 | await provider.logout(req, res) 29 | } else { 30 | res.redirect(parseReturnPath(req)) 31 | } 32 | }) 33 | 34 | app.get("/google-callback", async (req, res) => { 35 | const code = (req.query?.code as string) || "" 36 | const cookies = new Cookies(req, res) 37 | const returnTo = cookies.get("returnTo") || "/" 38 | cookies.set("returnTo", "", { maxAge: 0, httpOnly: true }) 39 | console.log("Verifying google auth", code) 40 | try { 41 | const userInfo = await provider.getAccountFromCode(code) 42 | console.log("Found", userInfo) 43 | setAuthenticatedUser(req, res, userInfo) 44 | res.redirect(returnTo) 45 | } catch (e) { 46 | console.error(e) 47 | res.status(500).send("Internal error") 48 | } 49 | }) 50 | 51 | function parseReturnPath(req: Request) { 52 | return (req.query.returnTo as string) || "/" 53 | } 54 | } 55 | 56 | export const authProvider: AuthProvider | null = googleConfig 57 | ? GoogleAuthProvider(googleConfig) 58 | : genericOIDCConfig 59 | ? GenericOIDCAuthProvider(genericOIDCConfig) 60 | : null 61 | -------------------------------------------------------------------------------- /backend/src/openapi.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIV3 } from "openapi-types" 2 | import apiRouteDefs from "./api/api-routes.openapi" 3 | 4 | const openapiDoc: OpenAPIV3.Document = { 5 | openapi: "3.0.0", 6 | info: { 7 | title: "Ourboard API", 8 | version: "0.1.0", 9 | }, 10 | paths: apiRouteDefs.paths, 11 | } 12 | 13 | export default openapiDoc 14 | -------------------------------------------------------------------------------- /backend/src/professions.ts: -------------------------------------------------------------------------------- 1 | export const professions = [ 2 | "Accountant", 3 | "Actress", 4 | "Architect", 5 | "Astronomer", 6 | "Author", 7 | "Baker", 8 | "Bricklayer", 9 | "Bus driver", 10 | "Butcher", 11 | "Carpenter", 12 | "Chef", 13 | "Cleaner", 14 | "Coder", 15 | "Consultant", 16 | "Dentist", 17 | "Designer", 18 | "DevOps specialist", 19 | "Doctor", 20 | "Dustman", 21 | "Electrician", 22 | "Engineer", 23 | "Factory worker", 24 | "Farmer", 25 | "Fire fighter", 26 | "Fisherman", 27 | "Florist", 28 | "Gardener", 29 | "Hairdresser", 30 | "Journalist", 31 | "Judge", 32 | "Lawyer", 33 | "Lecturer", 34 | "Librarian", 35 | "Lifeguard", 36 | "Mechanic", 37 | "Model", 38 | "Newsreader", 39 | "Nurse", 40 | "Optician", 41 | "Painter", 42 | "Pharmacist", 43 | "Photographer", 44 | "Physician", 45 | "Pilot", 46 | "Plumber", 47 | "Pointy-haired boss", 48 | "Politician", 49 | "Postman", 50 | "Product Owner", 51 | "Real estate agent", 52 | "Receptionist", 53 | "Scientist", 54 | "Scrum master", 55 | "Secretary", 56 | "Shop assistant", 57 | "Tailor", 58 | "Taxi driver", 59 | "Teacher", 60 | "Tester", 61 | "Translator", 62 | "Traffic warden", 63 | "Travel agent", 64 | "Vet", 65 | "UNIX guru", 66 | "Waiter/Waitress", 67 | "Window cleaner", 68 | ] 69 | 70 | export function randomProfession() { 71 | return professions[Math.floor(Math.random() * professions.length)] 72 | } 73 | -------------------------------------------------------------------------------- /backend/src/require-auth.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response, NextFunction } from "express" 2 | import { getAuthenticatedUser } from "./http-session" 3 | 4 | export const REQUIRE_AUTH = process.env.REQUIRE_AUTH === "true" 5 | 6 | export function possiblyRequireAuth(app: Express) { 7 | if (REQUIRE_AUTH) { 8 | // Require authentication for all resources except the URLs bound by setupAuth above 9 | app.use("/", (req: Request, res: Response, next: NextFunction) => { 10 | if (!getAuthenticatedUser(req)) { 11 | res.redirect("/login") 12 | } else { 13 | next() 14 | } 15 | }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /backend/src/s3.ts: -------------------------------------------------------------------------------- 1 | import * as AWS from "aws-sdk" 2 | 3 | const s3Config = { 4 | region: "eu-north-1", 5 | apiVersion: "2006-03-01", 6 | signatureVersion: "v4", 7 | } 8 | 9 | let s3Instance: AWS.S3 | null = null 10 | 11 | export const s3 = () => { 12 | s3Instance = s3Instance || new AWS.S3(s3Config) 13 | return s3Instance 14 | } 15 | 16 | export function getSignedPutUrl(Key: string) { 17 | const signedUrlExpireSeconds = 60 * 5 18 | 19 | const url = s3().getSignedUrl("putObject", { 20 | Bucket: "r-board-assets", 21 | Key, 22 | Expires: signedUrlExpireSeconds, 23 | }) 24 | 25 | return url 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv" 2 | dotenv.config() 3 | 4 | import * as Http from "http" 5 | import { exampleBoard } from "../../common/src/domain" 6 | import { awaitSavingChanges } from "./board-state" 7 | import { createBoard, fetchBoard } from "./board-store" 8 | import { initDB } from "./db" 9 | import { startExpressServer } from "./express-server" 10 | import { terminateSessions } from "./websocket-sessions" 11 | 12 | let stopServer: (() => void) | null = null 13 | 14 | async function shutdown() { 15 | console.log("Shutdown initiated. Closing sockets.") 16 | if (stopServer) stopServer() 17 | terminateSessions() 18 | console.log("Shutdown in progress. Waiting for all changes to be saved...") 19 | await awaitSavingChanges() 20 | console.log("Shutdown complete. Exiting process.") 21 | process.exit(0) 22 | } 23 | 24 | process.on("SIGTERM", () => { 25 | console.log("Received SIGTERM. Initiating shutdown.") 26 | shutdown() 27 | }) 28 | 29 | const PORT = parseInt(process.env.PORT || "1337") 30 | const HTTPS_PORT = process.env.HTTPS_PORT ? parseInt(process.env.HTTPS_PORT) : undefined 31 | const BIND_UWEBSOCKETS_TO_PORT = process.env.BIND_UWEBSOCKETS_TO_PORT === "true" 32 | if (BIND_UWEBSOCKETS_TO_PORT && process.env.UWEBSOCKETS_PORT) { 33 | throw Error("Cannot have both UWEBSOCKETS_PORT and BIND_UWEBSOCKETS_TO_PORT envs") 34 | } 35 | const HTTP_PORT = BIND_UWEBSOCKETS_TO_PORT ? null : PORT 36 | const UWEBSOCKETS_PORT = BIND_UWEBSOCKETS_TO_PORT 37 | ? PORT 38 | : process.env.UWEBSOCKETS_PORT 39 | ? parseInt(process.env.UWEBSOCKETS_PORT) 40 | : null 41 | 42 | initDB() 43 | .then(async () => { 44 | if (!(await fetchBoard("default"))) { 45 | await createBoard(exampleBoard) 46 | } 47 | }) 48 | .then(() => { 49 | if (HTTP_PORT) { 50 | stopServer = startExpressServer(HTTP_PORT, HTTPS_PORT) 51 | } 52 | if (UWEBSOCKETS_PORT) { 53 | import("./uwebsockets-server").then((uwebsockets) => { 54 | uwebsockets.startUWebSocketsServer(UWEBSOCKETS_PORT) 55 | }) 56 | } 57 | }) 58 | .catch((e) => { 59 | console.error(e) 60 | }) 61 | -------------------------------------------------------------------------------- /backend/src/storage.ts: -------------------------------------------------------------------------------- 1 | import { getSignedPutUrl as s3GetSignedPutUrl } from "./s3" 2 | import { StorageBackend } from "./config" 3 | 4 | function localFSGetSignedPutUrl(Key: string): string { 5 | return "/assets/" + Key 6 | } 7 | 8 | export const createGetSignedPutUrl = (storageBackend: StorageBackend): ((key: string) => string) => 9 | storageBackend.type === "AWS" ? s3GetSignedPutUrl : localFSGetSignedPutUrl 10 | -------------------------------------------------------------------------------- /backend/src/tools/wait-for-db.ts: -------------------------------------------------------------------------------- 1 | import TcpPortUsed from "tcp-port-used" 2 | const port = 13338 3 | ;(async function () { 4 | console.log(`Waiting for DB to bind port ${port}...`) 5 | try { 6 | await TcpPortUsed.waitUntilUsed(port, 100, 10000) 7 | } catch { 8 | console.error("Timed out waiting for DB") 9 | process.exit(1) 10 | } 11 | })() 12 | -------------------------------------------------------------------------------- /backend/src/user-store.ts: -------------------------------------------------------------------------------- 1 | import { inTransaction, withDBClient } from "./db" 2 | import * as uuid from "uuid" 3 | import { EventUserInfo, Id, RecentBoard, EventUserInfoAuthenticated, ISOTimeStamp } from "../../common/src/domain" 4 | import { uniqBy } from "lodash" 5 | 6 | export function getUserIdForEmail(email: string): Promise { 7 | return inTransaction(async (client) => { 8 | let id: string | undefined = (await client.query("SELECT id FROM app_user WHERE email=$1", [email])).rows[0]?.id 9 | if (!id) { 10 | id = uuid.v4() 11 | await client.query("INSERT INTO app_user (id, email) VALUES ($1, $2);", [id, email]) 12 | } 13 | return id 14 | }) 15 | } 16 | 17 | export async function associateUserWithBoard( 18 | userId: string, 19 | boardId: Id, 20 | lastOpened: ISOTimeStamp = new Date().toISOString(), 21 | ) { 22 | try { 23 | await inTransaction(async (client) => { 24 | await client.query( 25 | `INSERT INTO user_board (user_id, board_id, last_opened) values ($1, $2, $3) 26 | ON CONFLICT (user_id, board_id) DO UPDATE SET last_opened=EXCLUDED.last_opened`, 27 | [userId, boardId, lastOpened], 28 | ) 29 | }) 30 | } catch (e) { 31 | console.error(`Failed to associate user ${userId} with board ${boardId}`) 32 | } 33 | } 34 | 35 | export async function dissociateUserWithBoard(userId: string, boardId: Id) { 36 | try { 37 | await inTransaction(async (client) => { 38 | await client.query(`DELETE FROM user_board WHERE user_id=$1 and board_id=$2`, [userId, boardId]) 39 | }) 40 | } catch (e) { 41 | console.error(`Failed to dissociate user ${userId} with board ${boardId}`) 42 | } 43 | } 44 | 45 | export async function getUserAssociatedBoards(user: EventUserInfoAuthenticated): Promise { 46 | const rows = ( 47 | await withDBClient((client) => 48 | client.query( 49 | "SELECT b.id, b.name, ub.last_opened FROM user_board ub JOIN board b on (ub.board_id = b.id) WHERE ub.user_id = $1", 50 | [user.userId], 51 | ), 52 | ) 53 | ).rows 54 | return rows.map((r) => { 55 | return { 56 | id: r.id, 57 | name: r.name, 58 | userEmail: user.email, 59 | opened: r.last_opened.toISOString(), 60 | } 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/ws-wrapper.ts: -------------------------------------------------------------------------------- 1 | import * as WebSocket from "ws" 2 | import * as uuid from "uuid" 3 | import { EventFromServer } from "../../common/src/domain" 4 | 5 | export const WsWrapper = (ws: WebSocket) => { 6 | const onError = (f: () => void) => { 7 | ws.addEventListener("error", f) 8 | } 9 | const onMessage = (f: (msg: object) => void) => { 10 | ws.addEventListener("message", (msg: any) => { 11 | try { 12 | f(JSON.parse(msg.data)) 13 | } catch (e) { 14 | console.error("Error in WsWrapper/onMessage. Closing connection.", e) 15 | ws.close() 16 | } 17 | }) 18 | } 19 | const onClose = (f: () => void) => { 20 | ws.addEventListener("close", f) 21 | } 22 | return { 23 | send: (buffer: Buffer) => { 24 | try { 25 | ws.send(buffer, { binary: false }) 26 | } catch (e) { 27 | ws.close() 28 | } 29 | }, 30 | onError, 31 | onMessage, 32 | onClose, 33 | id: uuid.v4(), 34 | close: () => ws.close(), 35 | } 36 | } 37 | export type WsWrapper = ReturnType 38 | 39 | type CachedBuffer = { msg: EventFromServer; buffer: Buffer } 40 | let cachedBuffer: CachedBuffer | null = null 41 | export function toBuffer(msg: EventFromServer) { 42 | // We cache the latest buffer to avoid creating a new buffer for every message. 43 | if (cachedBuffer && cachedBuffer.msg === msg) { 44 | return cachedBuffer.buffer 45 | } 46 | cachedBuffer = { msg, buffer: Buffer.from(JSON.stringify(msg)) } 47 | return cachedBuffer.buffer 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/y-websocket-server/Docs.ts: -------------------------------------------------------------------------------- 1 | import { WSSharedDoc } from "./WSSharedDoc" 2 | import { Persistence } from "./Persistence" 3 | 4 | export interface DocsOptions { 5 | persistence?: Persistence 6 | gc?: boolean 7 | } 8 | 9 | interface DocState { 10 | doc: WSSharedDoc 11 | fetchPromise: Promise 12 | } 13 | 14 | export class Docs { 15 | readonly docs = new Map() 16 | readonly persistence: Persistence | null 17 | readonly gc: boolean 18 | 19 | constructor(options: DocsOptions = {}) { 20 | this.persistence = options.persistence || null 21 | this.gc = options.gc ?? true 22 | } 23 | 24 | /** 25 | * Gets a Y.Doc by name, whether in memory or on disk 26 | */ 27 | getYDoc(docname: string): WSSharedDoc { 28 | return this.getDocState(docname).doc 29 | } 30 | 31 | async getYDocAndWaitForFetch(docname: string): Promise { 32 | const state = this.getDocState(docname) 33 | await state.fetchPromise 34 | return state.doc 35 | } 36 | 37 | private getDocState(docname: string): DocState { 38 | let state = this.docs.get(docname) 39 | if (!state) { 40 | const doc = new WSSharedDoc(this, docname) 41 | console.log(`Loading document ${doc.name} into memory`) 42 | doc.gc = this.gc 43 | if (this.persistence !== null) { 44 | void this.persistence.bindState(docname, doc) 45 | } 46 | const fetchPromise = 47 | this.persistence !== null ? this.persistence.bindState(docname, doc) : Promise.resolve() 48 | state = { doc, fetchPromise } 49 | this.docs.set(docname, state) 50 | } 51 | return state 52 | } 53 | 54 | deleteYDoc(doc: WSSharedDoc) { 55 | console.log(`Purging document ${doc.name} from memory`) 56 | this.docs.delete(doc.name) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /backend/src/y-websocket-server/Persistence.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs" 2 | 3 | export interface Persistence { 4 | bindState: (docName: string, ydoc: Y.Doc) => Promise 5 | writeState: (docName: string, ydoc: Y.Doc) => Promise 6 | } 7 | 8 | export function createLevelDbPersistence(persistenceDir: string): Persistence { 9 | console.info('Persisting documents to "' + persistenceDir + '"') 10 | // @ts-ignore 11 | const LeveldbPersistence = require("y-leveldb").LeveldbPersistence 12 | const ldb = new LeveldbPersistence(persistenceDir) 13 | return { 14 | bindState: async (docName, ydoc) => { 15 | const persistedYdoc = await ldb.getYDoc(docName) 16 | const newUpdates = Y.encodeStateAsUpdate(ydoc) 17 | ldb.storeUpdate(docName, newUpdates) 18 | Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)) 19 | ydoc.on("update", (update) => { 20 | ldb.storeUpdate(docName, update) 21 | }) 22 | }, 23 | writeState: async (docName, ydoc) => {}, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/y-websocket-server/Protocol.ts: -------------------------------------------------------------------------------- 1 | export const messageSync = 0 2 | export const messageAwareness = 1 3 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "..", 7 | "sourceMap": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /common/src/action-folding.ts: -------------------------------------------------------------------------------- 1 | import { arrayEquals, arrayIdAndKeysMatch, arrayIdMatch, idsOf } from "./arrays" 2 | import { 3 | AppEvent, 4 | BoardHistoryEntry, 5 | CURSOR_POSITIONS_ACTION_TYPE, 6 | MoveItem, 7 | isBoardHistoryEntry, 8 | isSameUser, 9 | } from "./domain" 10 | 11 | type FoldOptions = { 12 | cursorsOnly?: boolean 13 | } 14 | 15 | const defaultOptions = { 16 | foldAddUpdate: true, 17 | cursorsOnly: false, 18 | } 19 | 20 | export const CURSORS_ONLY: FoldOptions = { cursorsOnly: true } 21 | 22 | export function foldActions(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null { 23 | if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) { 24 | if (options.cursorsOnly) return null 25 | if (!isSameUser(a.user, b.user)) return null 26 | const folded = foldActions_(a, b, options) 27 | if (!folded) return null 28 | const firstSerial = a.firstSerial ? a.firstSerial : a.serial 29 | const serial = b.serial 30 | return { ...(folded as BoardHistoryEntry), serial, firstSerial } as BoardHistoryEntry 31 | } else { 32 | return foldActions_(a, b, options) 33 | } 34 | } 35 | /* 36 | Folding can be done if in any given state S, applying actions A and B consecutively can be replaced with a single action C. 37 | This function should return that composite action or null if folding is not possible. 38 | */ 39 | export function foldActions_(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null { 40 | if (a.action === CURSOR_POSITIONS_ACTION_TYPE && b.action === CURSOR_POSITIONS_ACTION_TYPE) { 41 | return b 42 | } 43 | if (a.action === "cursor.move" && b.action === "cursor.move" && b.boardId === a.boardId) { 44 | return b // This is a local cursor move 45 | } 46 | if (options.cursorsOnly) return null 47 | 48 | if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) { 49 | if (!isSameUser(a.user, b.user)) return null 50 | } 51 | if (a.action === "item.front") { 52 | if (b.action === "item.front" && b.boardId === a.boardId && arrayEquals(b.itemIds, a.itemIds)) return b 53 | } else if (a.action === "item.move") { 54 | if (b.action === "item.move" && b.boardId === a.boardId && everyMovedItemMatches(b, a)) return b 55 | } else if (a.action === "item.update") { 56 | if ( 57 | b.action === "item.update" && 58 | b.boardId === a.boardId && 59 | arrayIdAndKeysMatch(b.items, a.items) && 60 | arrayIdAndKeysMatch(b.connections ?? [], a.connections ?? []) 61 | ) { 62 | return b 63 | } 64 | } else if (a.action === "item.lock" || a.action === "item.unlock") { 65 | if (b.action === a.action && b.boardId === a.boardId && b.itemId === a.itemId) return b 66 | } else if (a.action === "connection.modify" && b.action === "connection.modify") { 67 | if (arrayIdMatch(a.connections, b.connections)) return b 68 | } else if (a.action === "connection.modify" && b.action === "connection.delete") { 69 | if (arrayEquals(b.connectionIds, idsOf(a.connections))) return b 70 | } 71 | return null 72 | } 73 | 74 | function everyMovedItemMatches(evt: MoveItem, evt2: MoveItem) { 75 | return arrayIdMatch(evt.items, evt2.items) && arrayIdMatch(evt.connections, evt2.connections) 76 | } 77 | 78 | export function addOrReplaceEvent(event: E, q: E[], options: FoldOptions = defaultOptions): E[] { 79 | for (let i = 0; i < q.length; i++) { 80 | let eventInQueue = q[i] 81 | const folded = foldActions(eventInQueue, event, options) 82 | if (folded) { 83 | return [...q.slice(0, i), folded, ...q.slice(i + 1)] as E[] 84 | } 85 | } 86 | return q.concat(event) 87 | } 88 | -------------------------------------------------------------------------------- /common/src/arrays.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isEqual } from "lodash" 2 | 3 | export function toArray(x: T | T[]) { 4 | if (isArray(x)) return x 5 | return [x] 6 | } 7 | 8 | export function arrayIdMatch(a: T[] | T, b: T[] | T) { 9 | return arrayEquals(idsOf(a), idsOf(b)) 10 | } 11 | 12 | export function arrayObjectKeysMatch(a: T[] | T, b: T[] | T) { 13 | return arrayEquals(keysOf(a), keysOf(b)) 14 | } 15 | 16 | export function arrayIdAndKeysMatch(a: T[] | T, b: T[] | T) { 17 | return arrayIdMatch(a, b) && arrayObjectKeysMatch(a, b) 18 | } 19 | 20 | export function idsOf(a: T[] | T): string[] { 21 | return toArray(a).map((x) => x.id) 22 | } 23 | 24 | export function keysOf(a: T[] | T): string[][] { 25 | return toArray(a).map((x) => Object.keys(x)) 26 | } 27 | 28 | export function arrayEquals(a: T[] | T, b: T[] | T) { 29 | return isEqual(toArray(a), toArray(b)) 30 | } 31 | 32 | export function arrayToRecordById(arr: T[], init: Record = {}): Record { 33 | return arr.reduce((acc: Record, elem: T) => { 34 | acc[elem.id] = elem 35 | return acc 36 | }, init) 37 | } 38 | -------------------------------------------------------------------------------- /common/src/assertNotNull.ts: -------------------------------------------------------------------------------- 1 | export function assertNotNull(x: T | null | undefined): T { 2 | if (x === null || x === undefined) throw Error("Assertion failed: " + x) 3 | return x 4 | } 5 | -------------------------------------------------------------------------------- /common/src/authenticated-user.ts: -------------------------------------------------------------------------------- 1 | export type OAuthAuthenticatedUser = { 2 | name: string 3 | email: string 4 | picture?: string 5 | domain: string | null 6 | } 7 | -------------------------------------------------------------------------------- /common/src/board-crdt-helper.ts: -------------------------------------------------------------------------------- 1 | import * as Y from "yjs" 2 | import { Board, Id, Item, QuillDelta, isTextItem } from "./domain" 3 | 4 | export function getCRDTField(doc: Y.Doc, itemId: Id, fieldName: string) { 5 | return doc.getText(`items.${itemId}.${fieldName}`) 6 | } 7 | 8 | export function augmentBoardWithCRDT(doc: Y.Doc, board: Board): Board { 9 | const items = augmentItemsWithCRDT(doc, Object.values(board.items)) 10 | return { 11 | ...board, 12 | items: Object.fromEntries(items.map((i) => [i.id, i])), 13 | } 14 | } 15 | 16 | export function augmentItemsWithCRDT(doc: Y.Doc, items: Item[]): Item[] { 17 | return items.map((item) => { 18 | if (isTextItem(item) && item.crdt) { 19 | const field = getCRDTField(doc, item.id, "text") 20 | const textAsDelta = field.toDelta() as QuillDelta 21 | const text = field.toString() 22 | return { ...item, textAsDelta, text } 23 | } 24 | return item 25 | }) 26 | } 27 | 28 | export function importItemsIntoCRDT(doc: Y.Doc, items: Item[], options?: { fallbackToText: boolean }) { 29 | for (const item of items) { 30 | if (isTextItem(item) && item.crdt) { 31 | if (item.textAsDelta) { 32 | getCRDTField(doc, item.id, "text").applyDelta(item.textAsDelta) 33 | } else if (options?.fallbackToText) { 34 | getCRDTField(doc, item.id, "text").insert(0, item.text) 35 | } else { 36 | throw Error("textAsDelta is missing ") 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /common/src/board-reducer.benchmark.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash" 2 | import { NOTE_COLORS } from "./colors" 3 | import { Board } from "./domain" 4 | import * as uuid from "uuid" 5 | import { boardReducer } from "./board-reducer" 6 | import { assertNotNull } from "./assertNotNull" 7 | 8 | function createBoard(): Board { 9 | const itemCount = 10000 10 | const board: Board = { 11 | id: "0f5b9d6c-02c2-4b81-beb7-3a3b9035e8a2", 12 | name: "Perf3", 13 | width: 800, 14 | height: 600, 15 | serial: 320577, 16 | connections: [], 17 | items: {}, 18 | } 19 | for (let i = 0; i < itemCount; i++) { 20 | const id = uuid.v4() 21 | board.items[id] = { 22 | id, 23 | type: "text", 24 | x: Math.random() * 800, 25 | y: Math.random() * 600, 26 | z: 3, 27 | width: 5, 28 | height: 5, 29 | text: "Hello world", 30 | fontSize: 12, 31 | locked: false, 32 | color: "#FBFC86", 33 | } 34 | } 35 | return board 36 | } 37 | 38 | const board = createBoard() 39 | const boardId = board.id 40 | const eventCount = 1000 41 | const items = Object.values(board.items) 42 | 43 | const started = new Date().getTime() 44 | for (let i = 0; i < eventCount; i++) { 45 | const target = assertNotNull(_.sample(items)) 46 | const updated = { ...target, text: "EDIT " + i, color: _.sample(NOTE_COLORS)?.color! } 47 | 48 | boardReducer( 49 | board, 50 | { 51 | action: "item.update", 52 | boardId, 53 | items: [updated], 54 | }, 55 | { inplace: true }, 56 | ) 57 | } 58 | const elapsed = new Date().getTime() - started 59 | console.log(`Processed ${eventCount} events in ${elapsed}ms. (${eventCount / elapsed} events/ms)`) 60 | -------------------------------------------------------------------------------- /common/src/colors.ts: -------------------------------------------------------------------------------- 1 | export const LIGHT_BLUE = "#9FECFC" 2 | export const LIGHT_GREEN = "#C8FC87" 3 | export const YELLOW = "#FBFC86" 4 | export const ORANGE = "#FDDF90" 5 | export const PINK = "#FDC4E7" 6 | export const LIGHT_PURPLE = "#E0BDFA" 7 | export const RED = "#F62A5C" 8 | export const BLACK = "#000000" 9 | export const LIGHT_GRAY = "#f4f4f6" 10 | export const WHITE = "#ffffff" 11 | export const TRANSPARENT = "#ffffff00" 12 | export const DEFAULT_NOTE_COLOR = YELLOW 13 | 14 | export const NOTE_COLORS = [ 15 | { name: "light-blue", color: LIGHT_BLUE }, 16 | { name: "light-green", color: LIGHT_GREEN }, 17 | { name: "yellow", color: YELLOW }, 18 | { name: "orange", color: ORANGE }, 19 | { name: "pink", color: PINK }, 20 | { name: "light-purple", color: LIGHT_PURPLE }, 21 | { name: "red", color: RED }, 22 | { name: "black", color: BLACK }, 23 | { name: "light-gray", color: LIGHT_GRAY }, 24 | { name: "white", color: WHITE }, 25 | { name: "transparent", color: TRANSPARENT }, 26 | ] 27 | -------------------------------------------------------------------------------- /common/src/geometry.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "./domain" 2 | export const origin = { x: 0, y: 0 } 3 | export type Coordinates = { x: number; y: number } 4 | export type Dimensions = { width: number; height: number } 5 | export type Rect = { x: number; y: number; width: number; height: number } 6 | export const ZERO_RECT = { x: 0, y: 0, height: 0, width: 0 } 7 | export function add(a: Coordinates, b: Coordinates) { 8 | return { x: a.x + b.x, y: a.y + b.y } 9 | } 10 | 11 | export function subtract(a: Coordinates, b: Coordinates) { 12 | return { x: a.x - b.x, y: a.y - b.y } 13 | } 14 | 15 | export function negate(a: Coordinates) { 16 | return { x: -a.x, y: -a.y } 17 | } 18 | 19 | export function multiply(a: Coordinates, factor: number) { 20 | return { x: a.x * factor, y: a.y * factor } 21 | } 22 | 23 | export function overlaps(a: Rect, b: Rect) { 24 | if (b.x >= a.x + a.width) return false 25 | if (b.x + b.width <= a.x) return false 26 | if (b.y >= a.y + a.height) return false 27 | if (b.y + b.height <= a.y) return false 28 | return true 29 | } 30 | 31 | export function equalRect(a: Rect, b: Rect) { 32 | return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height 33 | } 34 | 35 | export function distance(a: Coordinates, b: Coordinates) { 36 | return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) 37 | } 38 | 39 | export function containedBy(a: Point, b: Rect): boolean 40 | export function containedBy(a: Rect, b: Rect): boolean 41 | export function containedBy(a: Rect | Point, b: Rect) { 42 | if ("width" in a) { 43 | return a.x > b.x && a.y > b.y && a.x + a.width < b.x + b.width && a.y + a.height < b.y + b.height 44 | } else { 45 | return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height 46 | } 47 | } 48 | 49 | export function rectFromPoints(a: Coordinates, b: Coordinates) { 50 | const x = Math.min(a.x, b.x) 51 | const y = Math.min(a.y, b.y) 52 | 53 | const width = Math.abs(a.x - b.x) 54 | const height = Math.abs(a.y - b.y) 55 | 56 | return { x, y, width, height } 57 | } 58 | 59 | export function isRect(i: Point | Rect): i is Rect { 60 | return "width" in i 61 | } 62 | 63 | export function centerPoint(i: Point | Rect) { 64 | if (isRect(i)) { 65 | return { 66 | x: i.x + i.width / 2, 67 | y: i.y + i.height / 2, 68 | } 69 | } 70 | return i 71 | } 72 | -------------------------------------------------------------------------------- /common/src/sets.ts: -------------------------------------------------------------------------------- 1 | export function toggleInSet(item: T, set: Set) { 2 | if (set.has(item)) { 3 | return new Set([...set].filter((i) => i !== item)) 4 | } 5 | return new Set([...set].concat(item)) 6 | } 7 | 8 | export function difference(setA: Set, setB: Set) { 9 | let _difference = new Set(setA) 10 | for (let elem of setB) { 11 | _difference.delete(elem) 12 | } 13 | return _difference 14 | } 15 | 16 | export const emptySet = () => new Set() 17 | -------------------------------------------------------------------------------- /common/src/sleep.ts: -------------------------------------------------------------------------------- 1 | export function sleep(ms: number): Promise { 2 | return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)) 3 | } 4 | -------------------------------------------------------------------------------- /common/src/vector2.ts: -------------------------------------------------------------------------------- 1 | export type Vector2 = { x: number; y: number } 2 | 3 | export function Vector2(x: number, y: number) { 4 | return { x, y } 5 | } 6 | 7 | export function getAngleRad(v: Vector2) { 8 | const unit = withLength(v, 1) 9 | return Math.atan2(unit.y, unit.x) 10 | } 11 | 12 | export function getAngleDeg(v: Vector2) { 13 | return radToDeg(getAngleRad(v)) 14 | } 15 | 16 | export function getLength(v: Vector2) { 17 | return Math.sqrt(v.x * v.x + v.y * v.y) 18 | } 19 | 20 | export function withLength(v: Vector2, newLength: number) { 21 | return multiply(v, newLength / getLength(v)) 22 | } 23 | 24 | export function multiply(v: Vector2, multiplier: number) { 25 | return Vector2(v.x * multiplier, v.y * multiplier) 26 | } 27 | 28 | export function add(v: Vector2, other: Vector2) { 29 | return Vector2(v.x + other.x, v.y + other.y) 30 | } 31 | 32 | export function rotateRad(v: Vector2, radians: number) { 33 | var length = getLength(v) 34 | var currentRadians = getAngleRad(v) 35 | var resultRadians = radians + currentRadians 36 | var rotatedUnit = { x: Math.cos(resultRadians), y: Math.sin(resultRadians) } 37 | return withLength(rotatedUnit, length) 38 | } 39 | 40 | export function rotateDeg(v: Vector2, degrees: number) { 41 | return rotateRad(v, degToRad(degrees)) 42 | } 43 | 44 | export function degToRad(degrees: number) { 45 | return (degrees * 2 * Math.PI) / 360 46 | } 47 | 48 | export function radToDeg(rad: number) { 49 | return (rad * 360) / 2 / Math.PI 50 | } 51 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": false, 3 | "supportFile": false, 4 | "fixturesFolder": false, 5 | "retries": 2 6 | } 7 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.1" 2 | 3 | services: 4 | db: 5 | image: postgres:12 6 | restart: always 7 | ports: 8 | - 13338:5432 9 | environment: 10 | POSTGRES_USER: r-board 11 | POSTGRES_PASSWORD: secret 12 | 13 | keycloak-db: 14 | image: postgres:12 15 | restart: always 16 | ports: 17 | - 13339:5432 18 | environment: 19 | POSTGRES_USER: keycloak 20 | POSTGRES_PASSWORD: secret 21 | volumes: 22 | - ./keycloak/keycloak-db.dump:/docker-entrypoint-initdb.d/keycloak-db.dump.sql 23 | keycloak: 24 | depends_on: 25 | - keycloak-db 26 | image: quay.io/keycloak/keycloak:22.0.5 27 | command: start-dev --db postgres --db-url jdbc:postgresql://keycloak-db/keycloak --db-username keycloak --db-password secret 28 | ports: 29 | - 8080:8080 30 | environment: 31 | - KEYCLOAK_ADMIN=admin 32 | - KEYCLOAK_ADMIN_PASSWORD=admin 33 | - DATABASE_URL=postgres://keycloak:secreto@keycloak-db:5432/keycloak 34 | -------------------------------------------------------------------------------- /frontend/.sassrc: -------------------------------------------------------------------------------- 1 | { 2 | "includePaths": ["node_modules"] 3 | } -------------------------------------------------------------------------------- /frontend/index.tmpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | OurBoard 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rboard-frontend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@types/cookie": "^0.4.0", 8 | "@types/email-validator": "^1.0.6", 9 | "@types/js-cookie": "^3.0.6", 10 | "@types/lodash": "^4.14.161", 11 | "@types/md5": "^2.2.1", 12 | "@types/path-to-regexp": "^1.7.0", 13 | "@types/pretty-ms": "^5.0.1", 14 | "@types/quill": "^2.0.14", 15 | "@types/ramda": "^0.27.40", 16 | "@types/sanitize-html": "^1.27.1", 17 | "bezier-js": "^4.0.3", 18 | "binpackingjs": "^3.0.2", 19 | "cookie": "^0.4.1", 20 | "email-validator": "^2.0.4", 21 | "esbuild": "^0.8.57", 22 | "fp-ts": "^2.9.5", 23 | "harmaja": "^0.24", 24 | "harmaja-router": "^0.3.3", 25 | "io-ts": "^2.2.15", 26 | "js-cookie": "^3.0.5", 27 | "jsonwebtoken": "^8.5.1", 28 | "jwt-decode": "^3.1.2", 29 | "localforage": "^1.9.0", 30 | "lodash": "^4.17.20", 31 | "lonna": "^0.12.2", 32 | "md5": "^2.3.0", 33 | "path-to-regexp": "^6.2.0", 34 | "pretty-ms": "^7.0.1", 35 | "quill": "^1.3.7", 36 | "quill-cursors": "^4.0.2", 37 | "ramda": "^0.27.1", 38 | "rimraf": "^3.0.2", 39 | "sanitize-html": "^2.3.2", 40 | "sass": "^1.32.8", 41 | "uuid": "^8.3.0", 42 | "y-indexeddb": "^9.0.12", 43 | "y-quill": "^0.1.5", 44 | "y-websocket": "^1.5.3", 45 | "yjs": "^13.6.12" 46 | }, 47 | "scripts": { 48 | "build": "node esbuild.js build", 49 | "watch": "node esbuild.js watch", 50 | "tsc:watch": "tsc --watch --noEmit" 51 | }, 52 | "devDependencies": { 53 | "@types/jsonwebtoken": "^8.5.0", 54 | "@types/uuid": "^8.3.0", 55 | "chokidar": "^3.5.1", 56 | "core-js": "^3.8.3", 57 | "cssnano": "^4.1.10", 58 | "dotenv": "^8.2.0", 59 | "nodemon": "^2.0.4", 60 | "npm-run-all": "^4.1.5", 61 | "typescript": "^5.3" 62 | }, 63 | "browserslist": [ 64 | "since 2017-06" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /frontend/public/img/reaktor-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raimohanska/ourboard/171d44def676e8b2cd2542b36b459111a332f21d/frontend/public/img/reaktor-logo.jpg -------------------------------------------------------------------------------- /frontend/src/app.scss: -------------------------------------------------------------------------------- 1 | @import "style/variables.scss"; 2 | @import "style/global.scss"; 3 | @import "style/dashboard.scss"; 4 | @import "style/utils.scss"; 5 | @import "style/board.scss"; 6 | @import "style/tool-layer.scss"; 7 | @import "style/header.scss"; 8 | @import "style/modal.scss"; 9 | @import "style/sharing-modal.scss"; 10 | @import "style/user-info-modal.scss"; 11 | -------------------------------------------------------------------------------- /frontend/src/board-navigation.ts: -------------------------------------------------------------------------------- 1 | import { HarmajaRouter, Navigator } from "harmaja-router" 2 | import * as L from "lonna" 3 | import { Board, BoardStub, EventFromServer } from "../../common/src/domain" 4 | import "./app.scss" 5 | import { Dispatch } from "./store/server-connection" 6 | 7 | export const BOARD_PATH = "/b/:boardId" 8 | export const ROOT_PATH = "/" 9 | 10 | export const Routes = { 11 | [ROOT_PATH]: () => ({ page: "Dashboard" as const }), 12 | [BOARD_PATH]: ({ boardId }: { boardId: string }) => ({ page: "Board" as const, boardId }), 13 | "": () => ({ page: "NotFound" as const }), 14 | } 15 | export type Routes = typeof Routes 16 | 17 | export function BoardNavigation() { 18 | const result = HarmajaRouter(Routes) 19 | 20 | const nicknameFromURL = new URLSearchParams(location.search).get("nickname") 21 | if (nicknameFromURL) { 22 | localStorage.nickname = nicknameFromURL 23 | const search = new URLSearchParams(location.search) 24 | search.delete("nickname") 25 | document.location.search = search.toString() 26 | } 27 | 28 | const boardId = L.view(result, (r) => (r.page === "Board" ? r.boardId : undefined)) 29 | 30 | return { 31 | boardId, 32 | page: result, 33 | } 34 | } 35 | 36 | export function createBoardAndNavigate( 37 | newBoard: Board | BoardStub, 38 | dispatch: Dispatch, 39 | navigator: Navigator, 40 | serverEvents: L.EventStream, 41 | ) { 42 | dispatch({ action: "board.add", payload: newBoard }) 43 | serverEvents.forEach( 44 | (e) => e.action === "board.add.ack" && navigator.navigateByParams(BOARD_PATH, { boardId: newBoard.id }), 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/board/BoardViewMessage.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { Board } from "../../../common/src/domain" 4 | import { signIn } from "../google-auth" 5 | import { BoardAccessStatus } from "../store/board-store" 6 | import { UserSessionState } from "../store/user-session-store" 7 | 8 | export const BoardViewMessage = ({ 9 | boardAccessStatus, 10 | sessionState, 11 | board, 12 | }: { 13 | boardAccessStatus: L.Property 14 | sessionState: L.Property 15 | board: L.Property 16 | }) => { 17 | // TODO: login may be disabled due to Incognito mode or other reasons 18 | return L.combine(boardAccessStatus, L.view(sessionState, "status"), (s: BoardAccessStatus, sessionStatus) => { 19 | if (s === "not-found") { 20 | return ( 21 |
22 |
23 |

Board not found. A typo, maybe?

24 |
25 |
26 | ) 27 | } 28 | if (s === "denied-permanently") { 29 | return ( 30 |
31 |
32 |

33 | Sorry, access denied. Click here to sign in with another account. 34 |

35 |
36 |
37 | ) 38 | } 39 | if (s === "login-required") { 40 | if (sessionStatus === "login-failed") { 41 | return ( 42 |
43 |
44 | Something went wrong with logging in. Click here to try again. 45 |
46 |
47 | ) 48 | } 49 | 50 | return ( 51 |
52 |
53 | This board is for authorized users only. Click here to sign in. 54 |
55 |
56 | ) 57 | } 58 | return null 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/board/CursorsView.tsx: -------------------------------------------------------------------------------- 1 | import { componentScope, h, ListView } from "harmaja" 2 | import * as L from "lonna" 3 | import { UserCursorPosition, UserSessionInfo } from "../../../common/src/domain" 4 | import { CursorsStore } from "../store/cursors-store" 5 | import { BoardZoom } from "./board-scroll-and-zoom" 6 | import { Rect } from "../../../common/src/geometry" 7 | import _ from "lodash" 8 | 9 | export const CursorsView = ({ 10 | sessions, 11 | cursors, 12 | viewRect, 13 | }: { 14 | cursors: CursorsStore 15 | sessions: L.Property 16 | viewRect: L.Property 17 | }) => { 18 | const transitionFromCursorDelay = cursors.cursorDelay.pipe( 19 | L.changes, 20 | L.throttle(2000, componentScope()), 21 | L.map((d) => { 22 | const t = (Math.min(d, 1000) / 1000).toFixed(1) 23 | return `all ${t}s, top ${t}s` 24 | }), 25 | ) 26 | const transitionFromZoom = viewRect.pipe( 27 | L.changes, 28 | L.map(() => "none"), 29 | ) 30 | const transition = L.merge(transitionFromCursorDelay, transitionFromZoom).pipe( 31 | L.toProperty("none", componentScope()), 32 | ) 33 | 34 | const scope = componentScope() 35 | 36 | return ( 37 | 38 | observable={cursors.cursors} 39 | renderObservable={(sessionId: string, pos_: L.Property) => { 40 | const pos = pos_.pipe(L.skipDuplicates(_.isEqual), L.applyScope(scope)) 41 | const changes = pos.pipe(L.changes) 42 | const stale = L.merge( 43 | changes.pipe( 44 | L.debounce(1000), 45 | L.map(() => true), 46 | ), 47 | changes.pipe(L.map(() => false)), 48 | ).pipe(L.toProperty(false, scope)) 49 | const className = L.view(stale, (s) => (s ? "cursor stale" : "cursor")) 50 | const style = L.view(pos, transition, viewRect, (p, t, vr) => { 51 | const x = _.clamp(p.x, vr.x, vr.x + vr.width - 1) 52 | const y = _.clamp(p.y, vr.y, vr.y + vr.height - 1) 53 | return { 54 | transition: t, 55 | left: x + "em", 56 | top: y + "em", 57 | } 58 | }) 59 | const userInfo = L.view(sessions, (sessions) => { 60 | const session = sessions.find((s) => s.sessionId === sessionId) 61 | return { 62 | name: session ? session.nickname : null, 63 | picture: session && session.userType === "authenticated" ? : null, 64 | } 65 | }) 66 | 67 | return ( 68 | 69 | 70 | 71 | {L.view(userInfo, "picture")} 72 | {L.view(userInfo, "name")} 73 | 74 | 75 | ) 76 | }} 77 | getKey={(c: UserCursorPosition) => c.sessionId} 78 | /> 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/board/DragBorder.tsx: -------------------------------------------------------------------------------- 1 | import { h, Fragment } from "harmaja" 2 | import * as L from "lonna" 3 | import { Board, Connection } from "../../../common/src/domain" 4 | import { BoardCoordinateHelper } from "./board-coordinates" 5 | import { BoardFocus } from "./board-focus" 6 | import { Dispatch } from "../store/board-store" 7 | import { itemDragToMove } from "./item-dragmove" 8 | import { Tool, ToolController } from "./tool-selection" 9 | 10 | type Position = "left" | "right" | "top" | "bottom" 11 | 12 | export const DragBorder = ({ 13 | id, 14 | board, 15 | coordinateHelper, 16 | latestConnection, 17 | focus, 18 | toolController, 19 | dispatch, 20 | }: { 21 | id: string 22 | coordinateHelper: BoardCoordinateHelper 23 | latestConnection: L.Property 24 | focus: L.Atom 25 | board: L.Property 26 | toolController: ToolController 27 | dispatch: Dispatch 28 | }) => { 29 | return ( 30 | <> 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | 38 | function DragHandle({ position }: { position: Position }) { 39 | const ref = (e: HTMLElement) => 40 | itemDragToMove(id, board, focus, toolController, coordinateHelper, latestConnection, dispatch, false)(e) 41 | 42 | return 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/board/ImageView.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { Board, Connection, Image } from "../../../common/src/domain" 4 | import { AssetStore } from "../store/asset-store" 5 | import { Dispatch } from "../store/board-store" 6 | import { BoardCoordinateHelper } from "./board-coordinates" 7 | import { BoardFocus } from "./board-focus" 8 | import { itemDragToMove } from "./item-dragmove" 9 | import { itemSelectionHandler } from "./item-selection" 10 | import { ToolController } from "./tool-selection" 11 | import { itemZIndex } from "./zIndices" 12 | 13 | export const ImageView = ({ 14 | id, 15 | image, 16 | assets, 17 | board, 18 | isLocked, 19 | focus, 20 | toolController, 21 | coordinateHelper, 22 | latestConnection, 23 | dispatch, 24 | }: { 25 | board: L.Property 26 | id: string 27 | image: L.Property 28 | isLocked: L.Property 29 | focus: L.Atom 30 | toolController: ToolController 31 | coordinateHelper: BoardCoordinateHelper 32 | latestConnection: L.Property 33 | dispatch: Dispatch 34 | assets: AssetStore 35 | }) => { 36 | const { selected, onClick, onTouchStart } = itemSelectionHandler( 37 | id, 38 | "image", 39 | focus, 40 | toolController, 41 | board, 42 | coordinateHelper, 43 | latestConnection, 44 | dispatch, 45 | ) 46 | const tool = toolController.tool 47 | return ( 48 | 67 | ({ 68 | top: 0, 69 | left: 0, 70 | transform: `translate(${p.x}em, ${p.y}em)`, 71 | height: p.height + "em", 72 | width: p.width + "em", 73 | zIndex: itemZIndex(p), 74 | position: "absolute", 75 | } as any), 76 | )} 77 | > 78 | assets.getAsset(i.assetId, i.src))} /> 79 | {L.view(isLocked, (l) => l && 🔒)} 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/board/RectangularDragSelection.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { Rect } from "../../../common/src/geometry" 4 | 5 | export const RectangularDragSelection = ({ rect }: { rect: L.Property }) => { 6 | return L.view( 7 | rect, 8 | (r) => 9 | r && ( 10 | 19 | ), 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/board/SaveAsTemplate.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { Board } from "../../../common/src/domain" 4 | 5 | export const SaveAsTemplate = ({ board }: { board: L.Property }) => { 6 | const currSavedBoard = L.atom(null) 7 | 8 | function handleLocalTemplateSave() { 9 | const b = board.get() 10 | if (!b) return 11 | const saved = localStorage.getItem("rboard_templates") 12 | const templates = saved ? (JSON.parse(saved) as Record) : {} 13 | 14 | templates[b.name] = b 15 | localStorage.setItem("rboard_templates", JSON.stringify(templates)) 16 | currSavedBoard.set(b) 17 | } 18 | 19 | const changed = L.combineTemplate({ 20 | curr: currSavedBoard, 21 | next: board, 22 | }).pipe(L.map((c) => c.curr !== c.next)) 23 | return ( 24 |
  • (c ? "" : "disabled"))} 26 | data-test="palette-save-as-template" 27 | onClick={() => handleLocalTemplateSave()} 28 | > 29 | Save as template 30 |
  • 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/board/TextView.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { AccessLevel, Board, canWrite, getItemBackground, TextItem } from "../../../common/src/domain" 4 | import { emptySet } from "../../../common/src/sets" 5 | import { HTMLEditableSpan } from "../components/HTMLEditableSpan" 6 | import { Dispatch } from "../store/board-store" 7 | import { autoFontSize } from "./autoFontSize" 8 | import { BoardCoordinateHelper } from "./board-coordinates" 9 | import { BoardFocus, getSelectedItemIds } from "./board-focus" 10 | import { contrastingColor } from "./contrasting-color" 11 | import { ToolController } from "./tool-selection" 12 | 13 | interface TextViewProps { 14 | id: string 15 | item: L.Property 16 | dispatch: Dispatch 17 | board: L.Property 18 | toolController: ToolController 19 | accessLevel: L.Property 20 | focus: L.Atom 21 | itemFocus: L.Property<"none" | "selected" | "dragging" | "editing"> 22 | coordinateHelper: BoardCoordinateHelper 23 | element: L.Property 24 | } 25 | 26 | export function TextView({ 27 | id, 28 | item, 29 | dispatch, 30 | board, 31 | toolController, 32 | focus, 33 | coordinateHelper, 34 | itemFocus, 35 | accessLevel, 36 | element, 37 | }: TextViewProps) { 38 | const textAtom = L.atom(L.view(item, "text"), (text) => 39 | dispatch({ action: "item.update", boardId: board.get().id, items: [{ id, text }] }), 40 | ) 41 | const showCoords = false 42 | const focused = L.view(focus, (f) => getSelectedItemIds(f).has(id)) 43 | 44 | const setEditing = (e: boolean) => { 45 | if (toolController.tool.get() === "connect") return // Don't switch to editing in middle of connecting 46 | dispatch({ action: "item.front", boardId: board.get().id, itemIds: [id] }) 47 | focus.set( 48 | e 49 | ? { status: "editing", itemId: id } 50 | : { status: "selected", itemIds: new Set([id]), connectionIds: emptySet() }, 51 | ) 52 | } 53 | const color = L.view(item, getItemBackground, contrastingColor) 54 | const fontSize = autoFontSize( 55 | item, 56 | L.view(item, (i) => (i.fontSize ? i.fontSize : 1)), 57 | L.view(item, "text"), 58 | focused, 59 | coordinateHelper, 60 | element, 61 | ) 62 | return ( 63 | e.stopPropagation()} 66 | style={L.combineTemplate({ fontSize, color })} 67 | > 68 | f === "editing"), 73 | setEditing, 74 | ), 75 | editable: L.view(accessLevel, canWrite), 76 | }} 77 | /> 78 | {showCoords && {L.view(item, (p) => Math.floor(p.x) + ", " + Math.floor(p.y))}} 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /frontend/src/board/VideoView.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "harmaja" 2 | import * as L from "lonna" 3 | import { BoardCoordinateHelper } from "./board-coordinates" 4 | import { Board, Connection, Image, Video } from "../../../common/src/domain" 5 | import { BoardFocus } from "./board-focus" 6 | import { AssetStore } from "../store/asset-store" 7 | import { itemDragToMove } from "./item-dragmove" 8 | import { itemSelectionHandler } from "./item-selection" 9 | import { Dispatch } from "../store/board-store" 10 | import { Tool, ToolController } from "./tool-selection" 11 | import { DragBorder } from "./DragBorder" 12 | import { itemZIndex } from "./zIndices" 13 | 14 | export const VideoView = ({ 15 | id, 16 | video, 17 | assets, 18 | board, 19 | isLocked, 20 | focus, 21 | toolController, 22 | coordinateHelper, 23 | latestConnection, 24 | dispatch, 25 | }: { 26 | board: L.Property 27 | id: string 28 | video: L.Property