commit d57328655fd16ed1456e7790ee14db0654d345a2 Author: upstream-mirror Date: Wed Sep 23 16:16:17 2026 +0500 snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..90c5064 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +backend/dist +backend/localfiles +Dockerfile +frontend/.cache +ignore +latest.dump* +node_modules +npm-debug.log +**/node_modules +**/.env +**/yarn-error.log +**/.DS_Store \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..62d7c5b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true + +[package.json] +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..b4fab47 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,24 @@ +--- +name: Bug Report +about: Create a bug report to help us out +title: "" +labels: bug +assignees: "" +--- + +# Feature request story or issue + +## Screenshot + +YOUR SCREENSHOT HERE: + +## Url + +- Url where you took the screenshot: +- URL: + +## A sentence description of the problem + +Short DESCRIPTION: + +That's it! Thanks. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c5dae1f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,25 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature Request]" +labels: enhancement +assignees: "" +--- + +**Is your feature request related to a problem? There is a Please describe.** +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 [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Take a screenshot of the area you're working in** +Even if the feature request doesn't exist there yet, what area would the feature request help within? +SCREENSHOT: + +URL (of where you're working): + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e3aefca --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,83 @@ +name: build +on: [push] +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:12 + env: + POSTGRES_USER: r-board + POSTGRES_PASSWORD: secret + ports: + - 13338:5432 + # Set health checks to wait until postgres has started + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + matrix: + node-version: [18.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install deps + run: yarn + - name: Run unit tests + run: yarn test:unit + - name: Wait for DB + run: yarn wait-for-db + - name: Run integration tests + run: yarn test:integration + - name: Build + run: yarn build + - name: Start server + run: yarn start& + env: + SESSION_SIGNING_SECRET: notsosecretthing + - name: Prepare Playwright + run: npx playwright install chromium firefox + - name: Run playwright tests + run: yarn test:playwright + - name: Archive results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + playwright/results + lint: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Check code formatting & generated files + run: yarn lint + docker-image: + needs: test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v2 + - name: docker login + env: + DOCKER_HUB_USER: ${{secrets.DOCKER_HUB_USER}} + DOCKER_HUB_PASSWORD: ${{secrets.DOCKER_HUB_PASSWORD}} + run: docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD + - name: docker build + run: docker build . -t raimohanska/ourboard:latest -t raimohanska/ourboard:${{github.sha}} + - name: docker push + run: docker push raimohanska/ourboard:latest && docker push raimohanska/ourboard:${{github.sha}} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8603011 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.cache/ +node_modules/ +dist/ +yarn-error.log +.env +*.mp4 +backend/localfiles/ +/ignore +.DS_Store +latest.dump +.vscode +backups +playwright/results diff --git a/.huskyrc.js b/.huskyrc.js new file mode 100644 index 0000000..22a1f7c --- /dev/null +++ b/.huskyrc.js @@ -0,0 +1,5 @@ +module.exports = { + hooks: { + "pre-commit": "lint-staged", + }, +} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b5b7ddb --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +package.json +.cache/ +node_modules/ +dist/ +yarn-error.log +.env +*.mp4 +backend/localfiles/ +ignore/ diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..e5f910c --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + printWidth: 120, + semi: false, + trailingComma: "all", +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e6c558 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:18 as builder + +# Create app directory +WORKDIR /usr/src/app + +COPY package.json yarn.lock ./ +COPY frontend/package.json ./frontend/ +COPY backend/package.json ./backend/ +COPY perf-tester/package.json ./perf-tester/ + +RUN yarn install --frozen-lockfile --non-interactive + +COPY backend ./backend +COPY frontend ./frontend +COPY common ./common +COPY perf-tester ./perf-tester +COPY tsconfig.json . + +run yarn build + +FROM node:18 as runner + +COPY --from=builder /usr/src/app/backend/dist/index.js /usr/src/app/backend/dist/index.js +COPY --from=builder /usr/src/app/backend/migrations /usr/src/app/backend/migrations +COPY --from=builder /usr/src/app/frontend/public /usr/src/app/frontend/public +COPY --from=builder /usr/src/app/frontend/dist /usr/src/app/frontend/dist +WORKDIR /usr/src/app +EXPOSE 1337 + +WORKDIR /usr/src/app/backend +CMD [ "node", "dist/index.js" ] \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..91e06df --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,8 @@ +This project is licensed under the MIT license. +Copyrights are respective of each contributor listed at the beginning of each definition file. + +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..54fecc4 --- /dev/null +++ b/README.md @@ -0,0 +1,437 @@ +An online whiteboard. Think of it as very poor man's Miro that's open source, free to use and which you can also host yourself. Feel free to try at https://www.ourboard.io/ + +In this Readme: + +- [User guide](#features-and-user-guide) +- [API](#api) +- [Development instructions](#development) +- [Hosting Ourboard](#hosting) + +## Features and User Guide + +The user guide here is bound to be incomplete and out-of-date. Feel welcome to improve it! + +### Basics + +Setting your nickname or sign in + +- Click on the top right corner nickname field to change your nickname +- Optionally, log in with your Google user account. This allows you to create private boards and to track your favorite boards across devices + +Adding items + +- Drag from palette +- Double click to add a new note +- Use keyboard shortcuts below + +Adding links + +- Paste a link to text, it'll automatically converted to a hyperlink +- Select text and paste a link to convert the text to a link + +Adding images + +- Add by dragging a file from your computer or from a browser window +- Add by pasting an image from the clipboard using Command-V + +Organizing your board + +- Create an Area and drag items on it to keep them together. When you move the Area, the items move with it! +- Use Areas, lines, connections and images to give a visual structure to your board +- Lock items in place to prevent accidental moves of static items and lines +- Use "structured stickies": 1. Create an Area to be used as a template, choose a nice color 2. Add template content, e.g.. labels like "size" 3. Lock the labels in place. Now you can clone the whole area with a single click on the Clone button (or Cmd-D). + +Copy and paste + +- You can cut/copy/paste contents on the board using keyboard shortcuts +- Copy-paste works across boards, so you can do a "backup" by selecting all notes and pasting on another board +- You should be able to paste text and images on the board from other applications as well +- You can create a full clone of your current board by clicking on the Clone button beside board name + +Keyboard shortcuts + +These are for Mac. For other Linux/Windows, replace Command with Control. + +``` +DEL/Backspace Delete item +A Create new area +N Create new note +T Create new text box +C Select the Connect tool +Esc Select the default tool +H Hide contents of an area +Command-A Select all items +Command-V Paste +Command-C Copy +Command-X Cut +Command-Z Undo +Command-Shift-Z Redo +Command-D Duplicate +Command-Minus Zoom out +Command-Plus Zoom in +Command-Zero Reset zoom +Arrow keys Move selected items. SHIFT for big steps, ALT for fine-tuning. +``` + +Pro tips + +- You can drag the toolbar / palette around, if it gets in your way at the top-center position + +### Board access controls + +All boards created accessible to anyone with the link by default. If you Sign In using Google authentication, you'll also be able to create boards with restricted access. It's possible to grant access to certain emails or to people with an email in a given domain. + +## API + +Ourboard has a limited HTTP API for creating, exporting and updating boards. + +All POST and PUT endpoints accept application/json content. + +API requests against boards with restricted access require you to supply an API_TOKEN header with a valid API token. +The token is returned in the response of the POST request used to create the board. + +### POST /api/v1/board + +Creates a new board. Payload: + +```js +{ + "name": "board name as string", +} +``` + +You can also specify board access policy, including individual users by email and user email domains: + +```js +{ + "name": "board name as string", + "accessPolicy": { + "allowList": [ + { email: "coolgirl@reaktor.com" }, + { domain: "reaktor.fi" } + ] + } +} +``` + +Response: + +```js +{ + "id": "board id", + "accessToken": "************" +} +``` + +The `accessToken` returned here is required for further API calls in case you set an access policy. So, make sure to save the token. + +### PUT /api/v1/board/:boardId + +Changes board name and, optionally, access policy. Payload is similar to the POST request above. + +This endpoint always requires the API_TOKEN header. + +### POST /api/v1/board/:boardId/item + +Creates a new item on given board. If you want to add the item onto a specific area/container element on the board, you can +find the id of the container by inspecting with your browser. + +To create a new item inside a container element: + +```js +{ + "type": "note", + "text": "text on note", + "container": "container element text or id", + "color": "hexadecimal color code" +} +``` + +To create a new item using coordinates: + +```js +{ + "type": "note", + "text": "text on note", + "color": "hexadecimal color code", + "x": 100, + "y": 100, + "width": 100, + "height": 100 +} +``` + +Response: + +```js +{ + "id": "ITEM_ID" +} +``` + +### PUT /api/v1/board/:boardId/item/:itemId + +Creates a new item on given board or updates an existing one. +If you want to add the item onto a specific area/container element on the board, you can +find the id of the container by inspecting with your browser. + +Payload: + +```js +{ + "type": "note", + "text": "text on note", + "container": "container element text or id", + "color": "hexadecimal color code", + "replaceTextIfExists": boolean, // Override text if item with this id exists. Defaults to false. + "replaceColorIfExists": boolean, // Override color if item with this id exists. Defaults to false. + "replaceContainerIfExists": boolean, // Override container in item with this id exists. Defaults to true. +} +``` + +or + +```js +{ + "x": "integer", + "y": "integer", + "type": "note", + "text": "text on note", + "color": "hexadecimal color code", + "width": "integer", + "height": "integer", +} +``` + +### GET /api/v1/board/:boardId + +Return board current state as JSON. + +### GET /api/v1/board/:boardId/hierarchy + +Return board current state in a hierarchical format (items inside containers) + +### GET /api/v1/board/:boardId/csv + +Return board current state in CSV format, where + +- A container containing only leaf items (note, text) creates a row and each item in that container gets its own column +- Container name is a column on the left +- Any wrapping containers also add a column on the left + +### GET /api/v1/board/:boardId/history + +Returns the full history of given board as JSON. + +## Github Issues Integration + +1. Create a board and an Area named "new issues" (case insensitive) on the board. +2. Add a webhook to a git repo, namely + 1. Use URL https://www.ourboard.io/api/v1/webhook/github/{board-id}, with board-id from the URL of you board. + 2. Use content type to application/json + 3. Select "Let me select individual events" and pick Issues only. +3. Create a new issue or change labels of an existing issue. +4. You should see new notes appear on your board + +## Development + +Running locally requires `docker-compose` which is used for starting the local PostgreSQL database. The script below starts the database, but you must make sure you have a working docker setup on your machine, of course. + +Running locally: + +``` +yarn install +yarn dev +``` + +Run end-to end Playwright tests + +- `yarn test:playwright` to run tests once +- `yarn test:playwright --ui` to open the Playwright UI + +Connect to the local PostgreSQL database + + yarn psql + +### Tech stack + +- TypeScript +- [Harmaja](https://github.com/raimohanska/harmaja) frontend library +- WebSockets (express-ws / uWebSockets.js both!) +- Express server +- Typera for HTTP API + +### Developing with production data + +Do not run your local server against the production database, or you'll corrupt production. The server's in memory state will be out of sync with DB and bad things will happen. + +Instead, do this. + +1. Capture a backup and download it: `heroku pg:backups:capture`, then `heroku pg:backups:download`. +2. Restore the backup to your local database: `pg_restore --verbose --clean --no-acl --no-owner -d postgres://r-board:secret@localhost:13338/r-board latest.dump` +3. Start you local server using `yarn dev` + +If you need the local state for a given board in localStorage, you can + +1. extract the content in the browser devtools, when viewing production site in browser, using `localStorage["board_"]` +2. Copy that string to clipboard +3. Run the following in your localhost site console: + + localStorage["board_32de1a50-09a6-4453-9b9e-ed10c56afa99"]=JSON.stringify( + + ) + +Copy the result string, navigate to your localhost site and paste the same value to the same localStorage key. Refresh and enjoy. + +### Building and pushing the raimohanska/ourboard docker image + +``` +docker login +docker build . -t raimohanska/ourboard:latest +docker push raimohanska/ourboard:latest +``` + +## Hosting + +OurBoard is made to be easy to deploy and host. It consists of a single Node.js process that needs a PostgreSQL database for storing data. Using environment variables (see below) you can set the URL for the database and optionally configure OurBoard to use S3 for image assets and Google for authentication. By default it comes without authentication / authorization and stores image assets in the local file system. + +### Heroku + +If it suits you, Heroku is likely to be the easiest way to host your own OurBoard server. You should be able to host your own OurBoard instance pretty easily in Heroku. This repository should be runnable as-is, +provided you set up some environment variables, which are listed below. + +### Docker + +OurBoard is available as a Docker image so you can deploy it with Docker or your favorite container environment of choice. To get an OurBoard docker image, you can either: + +1. Use the [raimohanska/ourboard image](https://hub.docker.com/r/raimohanska/ourboard) in Docker Hub (just skip to running, it will be downloaded automatically) +2. Build it from this repository: `docker build . -t raimohanska/ourboard:latest` + +You can run it like this: + +1. Start a posgres database. For example, running `docker-compose up` in your local clone of this directory will start one. +2. Start the Ourboard container. Run the example script `scripts/run_dockerized.sh` to try it out. + +With the example script, you'll have a setup which + +- Doesn't have authentication. See environment variables below for configuring Google authentication, which is the only supported option for now. +- Stores uploaded assets (images) on the local filesystem. The example script binds the local directory `backend/localfiles` to be used for storage. In your own script, you'll probably want to point out a more suitable directory on your server machine. +- Uses an absolutely insecure SESSION_SIGNING_KEY. Make sure to use a long random string instead. +- Uses plain HTTP and responds at http://localhost:1337. If you want it to respond at some other URL, you'll need to set the ROOT\__\_URL variable (and all the WS_ variables unless you have the latest ourboard image) + +Read on! + +### Environment variables + +The OurBoard server is configured using environment variables. Here's a most likely incomplete list of supported environment variables for the server. + +When developing the application locally, set these variables in `backend/.env` file. The most important first - you'll most likely need to set these. + +``` +DATABASE_URL Postgres database URL. In Heroku, you can just add the PostgreSQL add on and this variable will be correctly set. The free one will get you started. +ROOT_URL Root URL used for redirects. Use https:///. If you don't have authentication configured or you're actually planning to access your server using the address http://localhost:1337, you can omit this one. +PORT HTTP port that OurBoard should bind. Defaults to 1337. +``` + +HTTPS and TLS related settings: + +``` +HTTPS_PORT Local port to use for HTTPS sockets. Use this if you want OurBoard to terminate HTTPS. If you use a proxy like NGINX or run in a hosted environment like Heroku, you won't be needing this one. +HTTPS_CERT_FILE Path to HTTPS certificate file. When running in docker, make sure to add appropriate mounts to make the file available to the dockerized process. +HTTPS_KEY_FILE Path to HTTPS key file. When running in docker, make sure to add appropriate mounts to make the file available to the dockerized process. +REDIRECT_URL Put your OurBoard application root URL here, if you want the server to redirect all requests that don't have the x-forwarded-proto=https +DATABASE_SSL_ENABLED Use `true` to use SSL for database connection. Recommended. +``` + +AWS environment variables, needed for hosting board assets on AWS S3. If these are missing, all uploaded +assests (images, videos) will be stored in the local filesystem (using the path "localfiles"), +which is a viable solution only if you have a persistent file system with backups. + +``` +AWS_ACCESS_KEY_ID AWS access key ID +AWS_SECRET_ACCESS_KEY Secret access key +AWS_ASSETS_BUCKET_URL URL to the AWS bucket. For example https://r-board-assets.s3.eu-north-1.amazonaws.com +``` + +The experimental collaborative editing feature is controlled using environment variables as well: + +``` +COLLABORATIVE_EDITING `true` to enable for all new boards, `false` to disable for new boards, `opt-in` to allow opt-in on creation (default), `opt-in-authenticated` to allow opt-in for authenticated users only +``` + +And finally some more settings you're unlikely to need. + +``` +WS_HOST_DEFAULT Your domain name here, used for routing websocket connections. Is automatically derived from ROOT_URL in latest image. +WS_HOST_LOCAL Your domain name here as well. Is automatically derived from ROOT_URL in latest image. +WS_PROTOCOL `wss` for secure, `ws` for non-secure WebSockets. Is automatically derived from ROOT_URL in latest image. +BOARD_ALIAS_tutorial Board identifier for the "tutorial" board that will be cloned for new users. Allows you to create a custom tutorial board. For example, the value `782d4942-a438-44c9-ad5f-3187cc1d0a63` is used in ourboard.io, and this points to a publicly readable, but privately editable board +``` + +### Authentication configuration + +Ourboard can be configured to use Google or generic OpenID Connect (OIDC) authentication using environment variables. + +Even if you set an auth provider (see below), the server will default to allowing anonymous access as well - only boards that are explicitly set with access restrictions will require authentication. However, if you want to require authentication for all access, you can use the following environment variable. + +``` +REQUIRE_AUTH Use `true` to require authentication for all access. +``` + +#### Google authentication + +Google authentication is supported. To enable this feature, you'll need to supply the following environment variables. + +``` +GOOGLE_OAUTH_CLIENT_ID +GOOGLE_OAUTH_CLIENT_SECRET +``` + +You'll of course need to set up an account on the Google side and configure a client so that you can get the client id and secret variables you'll use on OurBoard side. When configuring the Google client, you should allow the URL `/google-callback` as a valid callback URL. + +#### OpenID Connect configuration + +Generic OpenID Connect (OIDC) authentication is also supported as an experimental feature. To enable this feature, you'll need to supply the following environment variables. + +``` +OIDC_CONFIG_URL Your OpenID configuration endpoint. For example: https://accounts.google.com/.well-known/openid-configuration +OIDC_CLIENT_ID Your OAuth2 client id +OIDC_CLIENT_SECRET Your OAuth2 client secret +OIDC_LOGOUT URL to redirect the user after a logout on Ourboard. This allows you to sign out from the OIDC provider. You can also use the value `true` to automatically determine this URL based on the `end_session_endpoint` field in the response from the OIDC_CONFIG_URL endpoint. If omitted and `REQUIRE_AUTH=true` is not set, OurBoard will simply allow anonymous usage after a logout. +``` + +You'll of course need an external auth provider and configure a client so that you can get the client id and secret variables you'll use on OurBoard side. When configuring the OIDC client, you should allow the URL `/google-callback` as a valid callback URL. OurBoard uses the OAuth "standard flow" or "authorization code flow" and expects to be able to find your OIDC configuration at the URL pointed by tge `OIDC_CONFIG_URL` environment variable. + +In the Id Token received from the Auth provider, OurBoard expects to find the following claims: + +- Either `name` or `preferred_username` representing the display name for the user +- `email` representing the email address of the user. OurBoard does not expect this to be a valid email address; it just uses the email as the unique identifier for the user. +- Optional `picture` for a URL for the user's profile picture + +Thus far, I've tested Ourboard OIDC with Google and Keycloak. + +#### OpenID Connect Using KeyCloak + +NOTICE: This is just a simple example for testing and **not a production-grade setup**. Make sure to configure Keycloak properly before using it in production. + +An example KeyCloak setup is bundled with the OurBoard development environment. To try it, set the following environment variables in `backend/.env`: + +``` +OICD_CONFIG_URL=http://127.0.0.1:8080/realms/ourboard/.well-known/openid-configuration +OIDC_CLIENT_ID=ourboard +OIDC_CLIENT_SECRET=S2qHjCg12IDxz89Lffo49NQ19ooWCUwF +``` + +When you start OurBoard in development mode using `yarn dev-with-keycloak`, you can now Sign In using the username `ourboard-test` and password `password`. + +## Contribution + +See Issues! + +## A word from our sponsor + +I want to thank [Reaktor](https://www.reaktor.com/) for the huge and essential support for this project! + +- We (Reaktorian contributors) get some monetary support from the Reaktor open-source support program +- Hosting costs covered by Reaktor (Heroku, AWS) +- The UI design was done by Reaktor's experts (Mira Myllylä for the tool and Mari Halla-aho for the dashboard) diff --git a/backend/migrations/001_init.js b/backend/migrations/001_init.js new file mode 100644 index 0000000..aea46db --- /dev/null +++ b/backend/migrations/001_init.js @@ -0,0 +1,16 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS board (id text PRIMARY KEY, name text NOT NULL); + ALTER TABLE board ADD COLUMN IF NOT EXISTS content JSONB NOT NULL; + ALTER TABLE board ADD COLUMN IF NOT EXISTS history JSONB NOT NULL default '[]'; + 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)); + ALTER TABLE board_event ALTER COLUMN last_serial SET NOT NULL; + ALTER TABLE board_event ALTER COLUMN board_id SET NOT NULL; + CREATE TABLE IF NOT EXISTS board_api_token (board_id text REFERENCES board(id), token TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS app_user (id text PRIMARY KEY, email text NOT NULL); + 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)); + ALTER TABLE board ADD COLUMN IF NOT EXISTS ws_host TEXT NULL; + ALTER TABLE board ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NULL DEFAULT now(); + ALTER TABLE board_event ADD COLUMN IF NOT EXISTS saved_at TIMESTAMP NULL DEFAULT now(); + `) +} diff --git a/backend/migrations/002_add_first_serial.js b/backend/migrations/002_add_first_serial.js new file mode 100644 index 0000000..f3abc08 --- /dev/null +++ b/backend/migrations/002_add_first_serial.js @@ -0,0 +1,7 @@ +exports.up = (pgm) => { + pgm.sql(` + ALTER TABLE board_event ADD COLUMN IF NOT EXISTS first_serial int; + UPDATE board_event SET first_serial = COALESCE(CAST(events#>'{events, 0, serial}' as int), 0); + ALTER TABLE board_event ALTER COLUMN first_serial SET NOT NULL; + `) +} diff --git a/backend/migrations/003_refactor_access.sql b/backend/migrations/003_refactor_access.sql new file mode 100644 index 0000000..d9f0e94 --- /dev/null +++ b/backend/migrations/003_refactor_access.sql @@ -0,0 +1,27 @@ +alter table board add public_read boolean null; +alter table board add public_write boolean null; +create table board_access ( + board_id text not null references board(id), + domain text null, + email text null, + access text null +); + +with allow_json as ( +select id, jsonb_array_elements(content -> 'accessPolicy' -> 'allowList') as e +from board), + +allow_entry as ( + select id, e ->> 'domain' as domain, e ->> 'email' as email, e ->> 'access' as access + from allow_json +) + +insert into board_access(board_id, domain, email, access) ( + select ae.id as board_id, ae.domain, ae.email, ae.access + from allow_entry ae +); + +update board +set public_read = coalesce(content -> 'accessPolicy' ->> 'publicRead', 'false') :: boolean, + public_write = coalesce(content -> 'accessPolicy' ->> 'publicWrite', 'false') :: boolean +where content -> 'accessPolicy' is not null \ No newline at end of file diff --git a/backend/migrations/004_public_boards_access.sql b/backend/migrations/004_public_boards_access.sql new file mode 100644 index 0000000..f32d830 --- /dev/null +++ b/backend/migrations/004_public_boards_access.sql @@ -0,0 +1,4 @@ +update board +set public_read = 't', + public_write = 't' +where content -> 'accessPolicy' is null \ No newline at end of file diff --git a/backend/migrations/005_uuid_extension.sql b/backend/migrations/005_uuid_extension.sql new file mode 100644 index 0000000..682131d --- /dev/null +++ b/backend/migrations/005_uuid_extension.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; \ No newline at end of file diff --git a/backend/migrations/006_unique_email.sql b/backend/migrations/006_unique_email.sql new file mode 100644 index 0000000..b3ff487 --- /dev/null +++ b/backend/migrations/006_unique_email.sql @@ -0,0 +1 @@ +ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email); \ No newline at end of file diff --git a/backend/migrations/007_add_crdt_update.sql b/backend/migrations/007_add_crdt_update.sql new file mode 100644 index 0000000..dcbdfb8 --- /dev/null +++ b/backend/migrations/007_add_crdt_update.sql @@ -0,0 +1 @@ +alter table board_event add column crdt_update bytea null; \ No newline at end of file diff --git a/backend/migrations/008_allow_crdt_only_bundle.sql b/backend/migrations/008_allow_crdt_only_bundle.sql new file mode 100644 index 0000000..b47b029 --- /dev/null +++ b/backend/migrations/008_allow_crdt_only_bundle.sql @@ -0,0 +1,2 @@ +alter table board_event alter column first_serial drop not null; +alter table board_event add constraint first_serial_or_crdt check (crdt_update is not null OR first_serial is not null); \ No newline at end of file diff --git a/backend/migrations/009_drop_board_event_primary_key.sql b/backend/migrations/009_drop_board_event_primary_key.sql new file mode 100644 index 0000000..d3131b5 --- /dev/null +++ b/backend/migrations/009_drop_board_event_primary_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE board_event DROP CONSTRAINT board_event_pkey; +CREATE INDEX board_event_board_index ON board_event (board_id); \ No newline at end of file diff --git a/backend/migrations/010_drop_history_column.sql b/backend/migrations/010_drop_history_column.sql new file mode 100644 index 0000000..440e56f --- /dev/null +++ b/backend/migrations/010_drop_history_column.sql @@ -0,0 +1 @@ +alter table board drop column history; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..510198f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,74 @@ +{ + "name": "rboard-backend", + "version": "1.0.0", + "main": "dist/index.js", + "license": "MIT", + "dependencies": { + "@types/cookies": "^0.7.7", + "@types/date-fns": "^2.6.0", + "@types/express-ws": "^3.0.0", + "@types/html-entities": "^1.2.16", + "@types/json-diff": "^0.5.0", + "@types/lodash": "^4.14.161", + "@types/node": "^14.6.2", + "@types/pg": "^7.14.4", + "@types/ramda": "^0.27.29", + "@types/tcp-port-used": "^1.0.0", + "@types/ws": "^7.4.0", + "aws-sdk": "^2.778.0", + "cookies": "^0.8.0", + "csv-writer": "^1.6.0", + "date-fns": "^2.17.0", + "dotenv": "^8.2.0", + "express": "^4.17.1", + "express-ws": "^4.0.0", + "fp-ts": "^2.9.5", + "google-auth-library": "^7.0.2", + "googleapis": "^110.0.0", + "html-entities": "^2.1.0", + "io-ts": "^2.2.15", + "io-ts-types": "^0.5.15", + "json-diff": "^0.5.4", + "jsonwebtoken": "^8.5.1", + "lodash": "^4.17.20", + "lonna": "^0.12.2", + "monocle-ts": "^2.3.7", + "newtype-ts": "^0.3.4", + "node-pg-migrate": "^6.0.0", + + "pg": "^8.3.3", + "pg-query-stream": "^4.2.1", + + "tcp-port-used": "^1.0.2", + "tsx": "3.13.0", + "typera-express": "^2.3.0", + + "typescript": "^5.3", + "uWebSockets.js": "uNetworking/uWebSockets.js#v18.14.0", + "uuid": "^8.3.0", + "uuid4": "^2.0.2", + "y-protocols": "^1.0.6" + }, + "scripts": { + "start": "node --enable-source-maps .", + "dev": "npm-run-all --parallel watch", + "build": "tsc && ncc build dist/backend/src/server.js -o dist", + "watch": "tsc-watch --onSuccess \"node --enable-source-maps dist/backend/src/server.js\" --preserveWatchOutput", + "watch-ts": "tsc-watch --preserveWatchOutput", + + + "lint": "echo 'No linting configured'" + }, + "engines": { + "node": ">=14" + }, + "devDependencies": { + "@types/express": "^4.17.7", + "@types/jsonwebtoken": "^8.5.0", + "@types/node-fetch": "^2.5.9", + + "@types/uuid": "^8.3.0", + "@vercel/ncc": "^0.38.1", + "tsc-watch": "^4.2.9" + } +} diff --git a/backend/src/api/api-routes.ts b/backend/src/api/api-routes.ts new file mode 100644 index 0000000..279fd8f --- /dev/null +++ b/backend/src/api/api-routes.ts @@ -0,0 +1,22 @@ +import { router } from "typera-express" +import { boardCreate } from "./board-create" +import { boardCSVGet } from "./board-csv-get" +import { boardGet } from "./board-get" +import { boardHierarchyGet } from "./board-hierarchy-get" +import { boardHistoryGet } from "./board-history-get" +import { boardUpdate } from "./board-update" +import { githubWebhook } from "./github-webhook" +import { itemCreate } from "./item-create" +import { itemCreateOrUpdate } from "./item-create-or-update" + +export default router( + boardGet, + boardHierarchyGet, + boardCSVGet, + boardCreate, + boardUpdate, + githubWebhook, + itemCreate, + itemCreateOrUpdate, + boardHistoryGet, +) diff --git a/backend/src/api/board-create.ts b/backend/src/api/board-create.ts new file mode 100644 index 0000000..cfe64db --- /dev/null +++ b/backend/src/api/board-create.ts @@ -0,0 +1,29 @@ +import * as t from "io-ts" +import { NonEmptyString } from "io-ts-types" +import { ok } from "typera-common/response" +import { body } from "typera-express/parser" +import { + Board, + BoardAccessPolicyCodec, + CrdtDisabled, + CrdtEnabled, + newBoard, + optional, +} from "../../../common/src/domain" +import { addBoard } from "../board-state" +import { route } from "./utils" +import { getConfig } from "../config" +/** + * Creates a new board. + * + * @tags Board + */ +export const boardCreate = route + .post("/api/v1/board") + .use(body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec, crdt: optional(t.literal(true)) }))) + .handler(async (request) => { + const crdt = (getConfig().crdt === "true" || request.body.crdt) ?? false + let board: Board = newBoard(request.body.name, crdt ? CrdtEnabled : CrdtDisabled, request.body.accessPolicy) + const boardWithHistory = await addBoard(board, true) + return ok({ id: boardWithHistory.board.id, accessToken: boardWithHistory.accessTokens[0] }) + }) diff --git a/backend/src/api/board-csv-get.ts b/backend/src/api/board-csv-get.ts new file mode 100644 index 0000000..f3f65b3 --- /dev/null +++ b/backend/src/api/board-csv-get.ts @@ -0,0 +1,62 @@ +import { createArrayCsvStringifier } from "csv-writer" +import _ from "lodash" +import { ok } from "typera-common/response" +import { Board, Container, Item, TextItem } from "../../../common/src/domain" +import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" +import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" +import { yWebSocketServer } from "../board-yjs-server" + +/** + * Gets board current contents + * + * @tags Board + */ +export const boardCSVGet = route + .get("/api/v1/board/:boardId/csv") + .use(apiTokenHeader) + .handler((request) => + checkBoardAPIAccess(request, async (boardState) => { + const board = augmentBoardWithCRDT( + await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), + boardState.board, + ) + const textItemsWithParent = Object.values(board.items).filter( + (i) => i.containerId !== undefined && (i.type === "text" || i.type === "note"), + ) + const textItemGroups = _.groupBy(textItemsWithParent, (i) => i.containerId) + const rows = Object.entries(textItemGroups).map(([parentId, textItems]) => { + const rowContainer = board.items[parentId] + return { + parents: parentChain(board)(rowContainer), + rowContainer, + textItems, + } as Row + }) + if (rows.length === 0) return csv(board, []) + const maxDepth = _.max(rows.map((r) => r.parents.length))! + const csvData = rows.map((r) => { + return [ + ...r.parents.map((c) => c.text), + ..._.times(maxDepth - r.parents.length, () => ""), + r.rowContainer.text, + ...r.textItems.map((i) => i.text), + ] + }) + return csv(board, csvData) + }), + ) + +type Row = { parents: Container[]; rowContainer: Container; textItems: TextItem[] } + +const parentChain = (board: Board) => (item: Item): Container[] => { + if (!item.containerId) return [] + const parent = board.items[item.containerId] + if (parent.type !== "container") + throw Error(`Parent item ${item.containerId} is of type ${parent.type}, expecting container`) + return [parent, ...parentChain(board)(parent)] +} + +function csv(board: Board, rows: string[][]) { + const result = createArrayCsvStringifier({}).stringifyRecords(rows) + return ok(result, { "content-type": "text/csv", "content-disposition": `attachment; filename=${board.name}.csv` }) +} diff --git a/backend/src/api/board-get.ts b/backend/src/api/board-get.ts new file mode 100644 index 0000000..57575cf --- /dev/null +++ b/backend/src/api/board-get.ts @@ -0,0 +1,22 @@ +import { ok } from "typera-common/response" +import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" +import { yWebSocketServer } from "../board-yjs-server" +import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" + +/** + * Gets board current contents + * + * @tags Board + */ +export const boardGet = route + .get("/api/v1/board/:boardId") + .use(apiTokenHeader) + .handler((request) => + checkBoardAPIAccess(request, async (boardState) => { + const board = augmentBoardWithCRDT( + await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), + boardState.board, + ) + return ok({ board }) + }), + ) diff --git a/backend/src/api/board-hierarchy-get.ts b/backend/src/api/board-hierarchy-get.ts new file mode 100644 index 0000000..19f6fe0 --- /dev/null +++ b/backend/src/api/board-hierarchy-get.ts @@ -0,0 +1,34 @@ +import { ok } from "typera-common/response" +import { Board, Item } from "../../../common/src/domain" +import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" +import { augmentBoardWithCRDT } from "../../../common/src/board-crdt-helper" +import { yWebSocketServer } from "../board-yjs-server" + +/** + * Gets board current contents + * + * @tags Board + */ +export const boardHierarchyGet = route + .get("/api/v1/board/:boardId/hierarchy") + .use(apiTokenHeader) + .handler((request) => + checkBoardAPIAccess(request, async (boardState) => { + const board = augmentBoardWithCRDT( + await yWebSocketServer.docs.getYDocAndWaitForFetch(boardState.board.id), + boardState.board, + ) + return ok({ board: getBoardHierarchy(board) }) + }), + ) + +export type ItemHierarchy = Item & { children: ItemHierarchy[] } +export function getBoardHierarchy(board: Board) { + const allItems = Object.values(board.items) + const rootItems = allItems.filter((i) => i.containerId === undefined).map(getItemHierarchy(allItems)) + return { ...board, items: rootItems } +} +const getItemHierarchy = (items: Item[]) => (item: Item): ItemHierarchy => { + const children: ItemHierarchy[] = items.filter((i) => i.containerId === item.id).map(getItemHierarchy(items)) + return { ...item, children } +} diff --git a/backend/src/api/board-history-get.ts b/backend/src/api/board-history-get.ts new file mode 100644 index 0000000..da8ee32 --- /dev/null +++ b/backend/src/api/board-history-get.ts @@ -0,0 +1,47 @@ +import { ok, streamingBody } from "typera-common/response" +import { getFullBoardHistory } from "../board-store" +import { withDBClient } from "../db" +import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" + +/** + * List the history of a board + * + * @tags Board + */ +export const boardHistoryGet = route + .get("/api/v1/board/:boardId/history") + .use(apiTokenHeader) + .handler((request) => + checkBoardAPIAccess(request, async (board) => { + return ok( + streamingJSONBody("history", async (callback) => { + await withDBClient( + async (client) => + await getFullBoardHistory(board.board.id, client, (bundle) => bundle.forEach(callback)), + ) + }), + ) + }), + ) + +function streamingJSONBody(fieldName: string, generator: (callback: (item: any) => void) => Promise) { + return streamingBody(async (stream) => { + // Due to memory concerns we fetch board histories from DB as chunks, so this API + // response must also be chunked + try { + stream.write(`{"${fieldName}":[`) + let chunksProcessed = 0 + await generator((item) => { + let prefix = chunksProcessed === 0 ? "" : "," + stream.write(`${prefix}${JSON.stringify(item)}`) + chunksProcessed++ + }) + stream.write("]}") + stream.end() + //console.log(`Wrote ${chunksProcessed} chunks`) + } catch (e) { + console.error(`Error writing a streamed body: ${e}`) + stream.end() + } + }) +} diff --git a/backend/src/api/board-update.ts b/backend/src/api/board-update.ts new file mode 100644 index 0000000..a751d4e --- /dev/null +++ b/backend/src/api/board-update.ts @@ -0,0 +1,29 @@ +import * as t from "io-ts" +import { NonEmptyString } from "io-ts-types" +import { ok } from "typera-common/response" +import { body } from "typera-express/parser" +import { BoardAccessPolicyCodec } from "../../../common/src/domain" +import { apiTokenHeader, checkBoardAPIAccess, dispatchSystemAppEvent, route } from "./utils" +import { renameBoardConvenienceColumnOnly, updateBoardAccessPolicy } from "../board-store" + +/** + * Changes board name and, optionally, access policy. + * + * @tags Board + */ +export const boardUpdate = route + .put("/api/v1/board/:boardId") + .use(apiTokenHeader, body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec }))) + .handler((request) => + checkBoardAPIAccess(request, async (board) => { + const { boardId } = request.routeParams + const { name, accessPolicy } = request.body + await renameBoardConvenienceColumnOnly(boardId, name) + dispatchSystemAppEvent(board, { action: "board.rename", boardId, name }) + if (accessPolicy) { + await updateBoardAccessPolicy(boardId, accessPolicy) + dispatchSystemAppEvent(board, { action: "board.setAccessPolicy", boardId, accessPolicy }) + } + return ok({ ok: true }) + }), + ) diff --git a/backend/src/api/github-webhook.ts b/backend/src/api/github-webhook.ts new file mode 100644 index 0000000..3cb9fe0 --- /dev/null +++ b/backend/src/api/github-webhook.ts @@ -0,0 +1,74 @@ +import { encode as htmlEncode } from "html-entities" +import * as t from "io-ts" +import { badRequest, internalServerError, ok } from "typera-common/response" +import { body } from "typera-express/parser" +import { RED, YELLOW } from "../../../common/src/colors" +import { Note } from "../../../common/src/domain" +import { getBoard } from "../board-state" +import { addItem, dispatchSystemAppEvent, InvalidRequest, route } from "./utils" + +// TODO: require API_TOKEN header for github too! +/** + * GitHub webhook + * + * @tags Webhooks + */ +export const githubWebhook = route + .post("/api/v1/webhook/github/:boardId") + .use( + body( + t.partial({ + issue: t.type({ + html_url: t.string, + title: t.string, + number: t.number, + state: t.string, + labels: t.array(t.type({ name: t.string })), + }), + }), + ), + ) + .handler(async (request) => { + try { + const boardId = request.routeParams.boardId + const body = request.body + const board = await getBoard(boardId) + if (!board) { + console.warn(`Github webhook call for unknown board ${boardId}`) + return ok() + } + if (body.issue) { + const url = body.issue.html_url + const title = body.issue.title + const number = body.issue.number.toString() + const state = body.issue.state + if (state !== "open") { + console.log(`Github webhook call board ${boardId}: Item in ${state} state`) + } else { + const linkStart = `` + const linkHTML = `${linkStart}${htmlEncode(number)} ${htmlEncode(title)}` + const existingItem = Object.values(board.board.items).find( + (i) => i.type === "note" && i.text.includes(url), + ) as Note | undefined + const isBug = body.issue.labels.some((l) => l.name === "bug") + const color = isBug ? RED : YELLOW + if (!existingItem) { + console.log(`Github webhook call board ${boardId}: New item`) + addItem(board, "note", linkHTML, color, "New issues") + } else { + console.log(`Github webhook call board ${boardId}: Item exists`) + const updatedItem: Note = { ...existingItem, color } + dispatchSystemAppEvent(board, { action: "item.update", boardId, items: [updatedItem] }) + } + } + } + return ok() + } catch (e) { + console.error(e) + if (e instanceof InvalidRequest) { + return badRequest(e.message) + } else { + return internalServerError() + } + } + }) diff --git a/backend/src/api/item-create-or-update.ts b/backend/src/api/item-create-or-update.ts new file mode 100644 index 0000000..6799e56 --- /dev/null +++ b/backend/src/api/item-create-or-update.ts @@ -0,0 +1,137 @@ +import * as t from "io-ts" +import { NonEmptyString } from "io-ts-types" +import _ from "lodash" +import { ok } from "typera-common/response" +import { body } from "typera-express/parser" +import { Color, isNote, Note } from "../../../common/src/domain" +import { ServerSideBoardState } from "../board-state" +import { + addItem, + apiTokenHeader, + checkBoardAPIAccess, + dispatchSystemAppEvent, + findContainer, + getItemAttributesForContainer, + InvalidRequest, + route, +} from "./utils" +/** + * Creates a new item on given board or updates an existing one. + * If you want to add the item onto a specific area/container element on the board, you can + * find the id of the container by inspecting with your browser. + * + * @tags Board + */ +export const itemCreateOrUpdate = route + .put("/api/v1/board/:boardId/item/:itemId") + .use( + apiTokenHeader, + body( + t.intersection([ + t.type({ + type: t.literal("note"), + text: NonEmptyString, + color: t.string, + }), + t.partial({ + container: t.string, + x: t.number, + y: t.number, + width: t.number, + height: t.number, + replaceTextIfExists: t.boolean, + replaceColorIfExists: t.boolean, + replaceContainerIfExists: t.boolean, + }), + ]), + ), + ) + .handler((request) => + checkBoardAPIAccess(request, async (board) => { + const { itemId } = request.routeParams + let { + type, + text, + color, + container, + replaceTextIfExists, + replaceColorIfExists, + replaceContainerIfExists = true, + ...rest + } = request.body + console.log(`PUT item for board ${board.board.id} item ${itemId}: ${JSON.stringify(request.req.body)}`) + const existingItem = board.board.items[itemId] + if (existingItem) { + updateItem( + board, + rest.x ?? existingItem.x, + rest.y ?? existingItem.y, + type, + text, + color, + container, + rest.width ?? existingItem.width, + rest.height ?? existingItem.height, + itemId, + replaceTextIfExists, + replaceColorIfExists, + replaceContainerIfExists, + ) + } else { + console.log(`Adding new item`) + addItem(board, type, text, color, container, itemId, rest) + } + return ok({ ok: true }) + }), + ) + +function updateItem( + board: ServerSideBoardState, + x: number, + y: number, + type: "note", + text: string, + color: Color, + container: string | undefined, + width: number, + height: number, + itemId: string, + replaceTextIfExists: boolean | undefined, + replaceColorIfExists: boolean | undefined, + replaceContainerIfExists: boolean | undefined, +) { + const existingItem = board.board.items[itemId] + + if (!isNote(existingItem)) { + throw new InvalidRequest("Unexpected item type") + } + + let updatedItem: Note = { + ...existingItem, + x: x !== undefined ? x : existingItem.x, + y: y !== undefined ? y : existingItem.y, + text: replaceTextIfExists !== false ? text : existingItem.text, + color: replaceColorIfExists !== false ? color || existingItem.color : existingItem.color, + width: width !== undefined ? width : existingItem.width, + height: height !== undefined ? height : existingItem.height, + } + + if (container && replaceContainerIfExists !== false) { + const containerItem = findContainer(container, board.board) + const currentContainer = findContainer(existingItem.containerId, board.board) + const containerAttrs = + containerItem !== currentContainer ? getItemAttributesForContainer(container, board.board) : {} + + updatedItem = { + ...updatedItem, + ...containerAttrs, + } + } + + if (!_.isEqual(updatedItem, existingItem)) { + console.log(`Updating existing item`) + dispatchSystemAppEvent(board, { action: "item.update", boardId: board.board.id, items: [updatedItem] }) + } else { + console.log(`Not updating: item not changed`) + } +} diff --git a/backend/src/api/item-create.ts b/backend/src/api/item-create.ts new file mode 100644 index 0000000..bf562d9 --- /dev/null +++ b/backend/src/api/item-create.ts @@ -0,0 +1,41 @@ +import * as t from "io-ts" +import { ok } from "typera-common/response" +import { body } from "typera-express/parser" +import { addItem, apiTokenHeader, checkBoardAPIAccess, route } from "./utils" + +/** + * Creates a new item on given board. If you want to add the item onto a + * specific area/container element on the board, you can find the id of the + * container by inspecting with your browser. + * + * @tags Board + */ +export const itemCreate = route + .post("/api/v1/board/:boardId/item") + .use( + apiTokenHeader, + body( + t.intersection([ + t.type({ + type: t.literal("note"), + text: t.string, + color: t.string, + }), + t.partial({ + container: t.string, + x: t.number, + y: t.number, + width: t.number, + height: t.number, + }), + ]), + ), + ) + .handler((request) => + checkBoardAPIAccess(request, async (board) => { + const { type, text, color, container, ...rest } = request.body + console.log(`POST item for board ${board.board.id}: ${JSON.stringify(request.req.body)}`) + const item = addItem(board, type, text, color, container, undefined, rest) + return ok(item) + }), + ) diff --git a/backend/src/api/utils.ts b/backend/src/api/utils.ts new file mode 100644 index 0000000..9204445 --- /dev/null +++ b/backend/src/api/utils.ts @@ -0,0 +1,124 @@ +import * as bodyParser from "body-parser" +import * as t from "io-ts" +import { badRequest, internalServerError, notFound } from "typera-common/response" +import { applyMiddleware } from "typera-express" +import { wrapNative } from "typera-express/middleware" +import { headers } from "typera-express/parser" +import { DEFAULT_NOTE_COLOR } from "../../../common/src/colors" +import { + AppEvent, + Board, + BoardHistoryEntry, + Color, + Container, + EventUserInfo, + newISOTimeStamp, + newNote, + Note, + PersistableBoardItemEvent, +} from "../../../common/src/domain" +import { getBoard, ServerSideBoardState, updateBoards } from "../board-state" +import { broadcastBoardEvent } from "../websocket-sessions" + +export const route = applyMiddleware(wrapNative(bodyParser.json())) +export const apiTokenHeader = headers(t.partial({ API_TOKEN: t.string })) + +export async function checkBoardAPIAccess( + request: { routeParams: { boardId: string }; headers: { API_TOKEN?: string | undefined } }, + fn: (board: ServerSideBoardState) => Promise, +) { + const boardId = request.routeParams.boardId + const apiToken = request.headers.API_TOKEN + try { + const board = await getBoard(boardId) + if (!board) return notFound() + if (board.board.accessPolicy || board.accessTokens.length) { + if (!apiToken) { + return badRequest("API_TOKEN header is missing") + } + if (!board.accessTokens.some((t) => t === apiToken)) { + console.log(`API_TOKEN ${apiToken} not on list ${board.accessTokens}`) + return badRequest("Invalid API_TOKEN") + } + } + return await fn(board) + } catch (e) { + console.error(e) + if (e instanceof InvalidRequest) { + return badRequest(e.message) + } else { + return internalServerError() + } + } +} + +export function findContainer(container: string | undefined, board: Board): Container | null { + if (container !== undefined) { + if (typeof container !== "string") { + throw new InvalidRequest("Expecting container to be undefined, or an id or name of an Container item") + } + const containerItem = Object.values(board.items).find( + (i) => i.type === "container" && (i.text.toLowerCase() === container.toLowerCase() || i.id === container), + ) + if (!containerItem) { + throw new InvalidRequest(`Container "${container}" not found by id or name`) + } + return containerItem as Container + } else { + return null + } +} + +export function getItemAttributesForContainer(container: string | undefined, board: Board) { + const containerItem = findContainer(container, board) + if (containerItem) { + return { + containerId: containerItem.id, + x: containerItem.x + 2, + y: containerItem.y + 2, + } + } + return {} +} + +export function dispatchSystemAppEvent(board: ServerSideBoardState, appEvent: PersistableBoardItemEvent) { + const user: EventUserInfo = { userType: "system", nickname: "Github webhook" } + let historyEntry: BoardHistoryEntry = { ...appEvent, user, timestamp: newISOTimeStamp() } + console.log(JSON.stringify(historyEntry)) + // TODO: refactor, this is the same sequence as done in connection-handler for messages from clients + const serial = updateBoards(board, historyEntry) + historyEntry = { ...historyEntry, serial } + broadcastBoardEvent(historyEntry) +} + +export function addItem( + board: ServerSideBoardState, + type: "note", + text: string, + color: Color, + container: string | undefined, + itemId?: string, + partialParams?: Partial<{ x: number; y: number; width: number; height: number }>, +) { + if (type !== "note") throw new InvalidRequest("Expecting type: note") + if (typeof text !== "string" || text.length === 0) throw new InvalidRequest("Expecting non zero-length text") + + let itemAttributes: object = getItemAttributesForContainer(container, board.board) + if (itemId) itemAttributes = { ...itemAttributes, id: itemId } + + // Merge partial parameters with existing attributes + if (partialParams) { + itemAttributes = { ...itemAttributes, ...partialParams } + } + + const item: Note = { ...newNote(text, color || DEFAULT_NOTE_COLOR), ...itemAttributes } + const appEvent: AppEvent = { action: "item.add", boardId: board.board.id, items: [item], connections: [] } + dispatchSystemAppEvent(board, appEvent) + return item +} + +export class InvalidRequest extends Error { + constructor(message: string) { + super(message) + } +} diff --git a/backend/src/board-event-handler.ts b/backend/src/board-event-handler.ts new file mode 100644 index 0000000..502176f --- /dev/null +++ b/backend/src/board-event-handler.ts @@ -0,0 +1,180 @@ +import { + AppEvent, + BoardHistoryEntry, + canWrite, + checkBoardAccess, + Id, + isBoardItemEvent, + isPersistableBoardItemEvent, + newISOTimeStamp, +} from "../../common/src/domain" +import { getBoard, maybeGetBoard, updateBoards } from "./board-state" +import { getBoardInfo, renameBoardConvenienceColumnOnly, updateBoardAccessPolicy } from "./board-store" +import { handleCommonEvent } from "./common-event-handler" +import { MessageHandlerResult } from "./connection-handler" +import { WS_HOST_DEFAULT, WS_HOST_LOCAL, WS_PROTOCOL } from "./host-config" +import { obtainLock } from "./locker" +import { associateUserWithBoard } from "./user-store" +import { addSessionToBoard, broadcastBoardEvent, getSession } from "./websocket-sessions" +import { toBuffer, WsWrapper } from "./ws-wrapper" + +export const handleBoardEvent = (allowedBoardId: Id | null, getSignedPutUrl: (key: string) => string) => async ( + socket: WsWrapper, + appEvent: AppEvent, +): Promise => { + if (await handleCommonEvent(socket, appEvent)) return true + const session = getSession(socket) + if (!session) { + console.error("Session missing for socket " + socket.id) + return true + } + if (appEvent.action === "board.join") { + //await sleep(3000) // simulate latency + + const boardInfo = await getBoardInfo(appEvent.boardId) + if (!boardInfo) { + console.warn(`Trying to join unknown board ${appEvent.boardId}`) + session.sendEvent({ + action: "board.join.denied", + boardId: appEvent.boardId, + reason: "not found", + }) + return true + } + const wsHost = boardInfo.ws_host ?? WS_HOST_DEFAULT + + if (!allowedBoardId || appEvent.boardId !== allowedBoardId || !WS_HOST_LOCAL.includes(wsHost)) { + // Path - board id mismatch -> always redirect + + const wsAddress = `${WS_PROTOCOL}://${wsHost}/socket/board/${appEvent.boardId}` + /* console.info( + `Trying to join board ${appEvent.boardId} on socket for board ${allowedBoardId}, board host ${wsHost} local hostnames ${WS_HOST_LOCAL}`, + )*/ + + session.sendEvent({ + action: "board.join.denied", + boardId: appEvent.boardId, + reason: "redirect", + wsAddress, + }) + return true + } + + let board = (await getBoard(appEvent.boardId))! + + const accessPolicy = board.board.accessPolicy + const accessLevel = checkBoardAccess(accessPolicy, session.userInfo) + if (session.userInfo.userType === "authenticated") { + if (accessLevel === "none") { + console.warn("Access denied to board by user not on allowList") + session.sendEvent({ + action: "board.join.denied", + boardId: appEvent.boardId, + reason: "forbidden", + }) + return true + } else { + await associateUserWithBoard(session.userInfo.userId, appEvent.boardId) + } + } else { + if (accessLevel === "none") { + console.warn("Access denied to board by anonymous user") + session.sendEvent({ + action: "board.join.denied", + boardId: appEvent.boardId, + reason: "unauthorized", + }) + return true + } + } + await addSessionToBoard(board, socket, accessLevel, appEvent.initAtSerial) + return true + } + + if (!session.boardSession) { + console.warn("Trying to send event to board without session", appEvent) + return true + } + + if (isBoardItemEvent(appEvent)) { + const boardId = appEvent.boardId + const state = await getBoard(boardId) + if (!state) { + return true // Just ignoring for now, see above todo + } + if (!canWrite(session.boardSession.accessLevel)) { + console.warn("Trying to change read-only board") + return true + } + obtainLock(state.locks, appEvent, socket) // Allow even if was locked (offline use) + if (isPersistableBoardItemEvent(appEvent)) { + if (!session.isOnBoard(appEvent.boardId)) { + console.warn("Trying to send event to board without valid session") + } else { + let historyEntry: BoardHistoryEntry = { + ...appEvent, + user: session.userInfo, + timestamp: newISOTimeStamp(), + } + try { + const serial = updateBoards(state, historyEntry) + historyEntry = { ...historyEntry, serial } + broadcastBoardEvent(historyEntry, session) + if (appEvent.action === "board.rename") { + // special case: keeping name up to date as it's in a separate column + await renameBoardConvenienceColumnOnly(appEvent.boardId, appEvent.name) + } + if (appEvent.action === "board.setAccessPolicy") { + if (session.boardSession.accessLevel !== "admin") { + console.warn("Trying to change access policy without admin access") + return true + } + await updateBoardAccessPolicy(appEvent.boardId, appEvent.accessPolicy) + } + return { boardId, serial } + } catch (e) { + console.warn(`Error applying event ${JSON.stringify(appEvent)}: ${e} -> forcing board refresh`) + session.sendEvent({ action: "board.action.apply.failed" }) + return true + } + } + } else if (appEvent.action === "item.unlock") { + return true + } + } else { + switch (appEvent.action) { + case "cursor.move": { + const { boardId, position } = appEvent + const { x, y } = position + const state = maybeGetBoard(boardId) + if (state) { + const session = getSession(socket) + if (session && session.isOnBoard(appEvent.boardId)) { + state.cursorPositions[socket.id] = { x, y, sessionId: socket.id } + state.cursorsMoved = true + } + } + return true + } + case "user.bringAllToMe": { + const session = getSession(socket) + const state = maybeGetBoard(appEvent.boardId) + if (session && state && session.isOnBoard(appEvent.boardId)) { + if (session.sessionId !== appEvent.sessionId) { + console.warn("Incorrect sessionId in user.bringAllToMe") + } else { + broadcastBoardEvent(appEvent, session) + } + } + return true + } + case "asset.put.request": { + const { assetId } = appEvent + const signedUrl = getSignedPutUrl(assetId) + socket.send(toBuffer({ action: "asset.put.response", assetId, signedUrl })) + return true + } + } + } + return false +} diff --git a/backend/src/board-state.test.ts b/backend/src/board-state.test.ts new file mode 100644 index 0000000..411aa5b --- /dev/null +++ b/backend/src/board-state.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest" + +describe("board state iteration", () => { + it("is safe", () => { + // Checking that map value iteration is safe when deleteting items on the way + const boards = new Map() + boards.set(1, 1) + boards.set(2, 2) + const results: number[] = [] + for (let b of boards.values()) { + results.push(b) + boards.delete(b) + } + expect(results).toEqual([1, 2]) + }) +}) diff --git a/backend/src/board-state.ts b/backend/src/board-state.ts new file mode 100644 index 0000000..7437ea1 --- /dev/null +++ b/backend/src/board-state.ts @@ -0,0 +1,205 @@ +import { merge } from "lodash" +import { boardReducer } from "../../common/src/board-reducer" +import { Board, BoardCursorPositions, BoardHistoryEntry, Id } from "../../common/src/domain" +import { sleep } from "../../common/src/sleep" +import { createAccessToken, createBoard, fetchBoard, storeEventHistoryBundle } from "./board-store" +import { quickCompactBoardHistory } from "./compact-history" +import { Locks } from "./locker" +import { UserSession, broadcastItemLocks, getBoardSessionCount, getSessionCount } from "./websocket-sessions" +import * as Y from "yjs" +import { inTransaction } from "./db" + +// A mutable state object for server side state +export type ServerSideBoardState = { + ready: true + board: Board + recentEvents: BoardHistoryEntry[] + recentCrdtUpdate: Uint8Array | null + currentlyStoring: { + events: BoardHistoryEntry[] + crdtUpdate: Uint8Array | null + } | null + locks: ReturnType + cursorsMoved: boolean + cursorPositions: BoardCursorPositions + accessTokens: string[] + sessions: UserSession[] +} + +export type ServerSideBoardStateInternal = + | ServerSideBoardState + | { + ready: false + fetch: Promise + } + +let boards: Map = new Map() + +export async function getBoard(id: Id): Promise { + let state = boards.get(id) + if (!state) { + console.log(`Loading board ${id} into memory`) + const fetchState = async () => { + const boardData = await fetchBoard(id) + if (!boardData) return null + const { board, accessTokens } = boardData + return { + ready: true, + board, + accessTokens, + recentEvents: [], + recentCrdtUpdate: null, + currentlyStoring: null, + locks: Locks((changedLocks) => broadcastItemLocks(id, changedLocks)), + cursorsMoved: false, + cursorPositions: {}, + sessions: [], + } as ServerSideBoardState + } + const fetch = fetchState() + const temporaryState = { + ready: false as const, + fetch, + } + boards.set(id, temporaryState) + try { + const finalState = await fetch + if (!finalState) { + boards.delete(id) + return null + } else { + boards.set(id, finalState) + console.log(`Board loaded into memory: ${id}`) + return finalState + } + } catch (e) { + boards.delete(id) + // TODO: avoid retry loop + console.error(`Board load failed for board ${id}`) + throw e + } + } else if (!state.ready) { + return await state.fetch + } else { + return state + } +} + +export function maybeGetBoard(id: Id): ServerSideBoardState | undefined { + const state = boards.get(id) + if (state?.ready) return state +} + +export function updateBoards(boardState: ServerSideBoardState, appEvent: BoardHistoryEntry) { + const currentSerial = boardState.board.serial + const serial = currentSerial + 1 + if (appEvent.serial !== undefined) { + throw Error("Event already has serial") + } + const eventWithSerial = { ...appEvent, serial } + + const updatedBoard = boardReducer(boardState.board, eventWithSerial, { inplace: true, strictOnSerials: true })[0] + + boardState.board = updatedBoard + boardState.recentEvents.push(eventWithSerial) + return serial +} + +export function updateBoardCrdt(id: Id, crdtUpdate: Uint8Array) { + const boardState = maybeGetBoard(id) + + if (!boardState) { + console.warn("CRDT update for board not loaded into memory", id) + } else { + boardState.recentCrdtUpdate = combineCrdtUpdates(boardState.recentCrdtUpdate, crdtUpdate) + } +} + +export async function addBoard(board: Board, createToken?: boolean): Promise { + await createBoard(board) + const accessTokens = createToken ? [await createAccessToken(board)] : [] + const boardState = { + ready: true as const, + board, + serial: 0, + recentEvents: [], + recentCrdtUpdate: null, + currentlyStoring: null, + locks: Locks((changedLocks) => broadcastItemLocks(board.id, changedLocks)), + cursorsMoved: false, + cursorPositions: {}, + accessTokens, + sessions: [], + } + boards.set(board.id, boardState) + return boardState +} + +export function getActiveBoards() { + return [...boards.values()].filter((b) => b.ready) as ServerSideBoardState[] +} + +let savingPromise: Promise = saveBoards() + +async function saveBoards() { + await sleep(1000) + for (let state of boards.values()) { + if (state.ready) await saveBoardChanges(state) + } + savingPromise = saveBoards() +} + +export async function awaitSavingChanges() { + await savingPromise +} + +async function saveBoardChanges(state: ServerSideBoardState) { + if (state.recentEvents.length > 0 || state.recentCrdtUpdate !== null) { + if (state.currentlyStoring) { + throw Error("Invariant failed: storingEvents not empty") + } + const events = state.recentEvents.splice(0) + const crdtUpdate = state.recentCrdtUpdate + state.currentlyStoring = { + events, + crdtUpdate, + } + state.recentCrdtUpdate = null + console.log( + `Saving board ${state.board.id} at serial ${state.board.serial} with ${ + state.currentlyStoring.events.length + } new events ${crdtUpdate ? "and CRDT update of size " + crdtUpdate.length : ""}`, + ) + const lastSerial = state.board.serial + try { + await inTransaction((client) => + storeEventHistoryBundle(state.board.id, events, lastSerial, crdtUpdate, client), + ) + } catch (e) { + // Push event back to the head of save list for retrying later + state.recentEvents = [...state.currentlyStoring.events, ...state.recentEvents] + state.recentCrdtUpdate = merge(state.currentlyStoring.crdtUpdate, state.recentCrdtUpdate) + console.error("Board save failed for board", state.board.id, e) + } + state.currentlyStoring = null + } + if (state.recentEvents.length === 0 && getBoardSessionCount(state.board.id) === 0) { + console.log(`Purging board ${state.board.id} from memory`) + boards.delete(state.board.id) + await quickCompactBoardHistory(state.board.id) + } +} + +export function combineCrdtUpdates(a: Uint8Array | null, b: Uint8Array | null) { + if (!a) return b + if (!b) return a + return Y.mergeUpdates([a, b]) +} + +export function getActiveBoardCount() { + return boards.size +} + +setInterval(() => { + console.log("Statistics: active boards " + getActiveBoardCount() + ", sessions " + getSessionCount()) +}, 60000) diff --git a/backend/src/board-store.ts b/backend/src/board-store.ts new file mode 100644 index 0000000..15c8b79 --- /dev/null +++ b/backend/src/board-store.ts @@ -0,0 +1,481 @@ +import { PoolClient } from "pg" +import QueryStream from "pg-query-stream" +import * as uuid from "uuid" +import { boardReducer } from "../../common/src/board-reducer" +import { Board, BoardAccessPolicy, BoardHistoryEntry, Id, isBoardEmpty, Serial } from "../../common/src/domain" +import { migrateBoard, migrateEvent, mkBootStrapEvent } from "../../common/src/migration" +import { inTransaction, withDBClient } from "./db" +import { assertNotNull } from "../../common/src/assertNotNull" + +export type BoardAndAccessTokens = { + board: Board + accessTokens: string[] +} + +export type BoardInfo = { + id: Id + name: string + ws_host: string | null +} + +export async function getBoardInfo(id: Id): Promise { + const result = await withDBClient((client) => client.query("SELECT id, name, ws_host FROM board WHERE id=$1", [id])) + return result.rows.length === 1 ? (result.rows[0] as BoardInfo) : null +} + +const selectBoardQuery = ` +select id, + jsonb_set (content - 'accessPolicy', '{accessPolicy}', + cast(json_build_object( + 'allowList', ( + coalesce(( + select jsonb_agg(jsonb_strip_nulls(jsonb_build_object('domain', domain, 'access', access, 'email', email))) + from board_access + where board_access.board_id = board.id + ), '[]') + ), + 'publicRead', public_read, + 'publicWrite', public_write + ) as jsonb)) + as content +from board +where id=$1 +` + +export async function fetchBoard(id: Id): Promise { + return await inTransaction(async (client) => { + const started = new Date().getTime() + const result = await client.query(selectBoardQuery, [id]) + if (result.rows.length == 0) { + return null + } else { + const snapshot = result.rows[0].content as Board + if ( + snapshot.accessPolicy && + snapshot.accessPolicy.allowList.length === 0 && + snapshot.accessPolicy.publicRead && + snapshot.accessPolicy.publicWrite + ) { + // Effectively no access policy + delete snapshot.accessPolicy + } + let historyEventCount = 0 + let lastSerial = 0 + let board = snapshot + + let i = 0 + let rebuildingSnapshot = false + function updateBoardWithEventChunk(chunk: BoardHistoryEntry[]) { + if (chunk.length === 0) { + return // CRDT-only bundle + } + board = chunk.reduce((b, e) => { + i++ + if (e.action === "board.setAccessPolicy") { + // Don't process access policy event when fetching board + // Access policy may have been changed in the database after the event + // And the board table status is considered the master + return { ...b, serial: assertNotNull(e.serial) } + } + return boardReducer(b, e, { inplace: true, strictOnSerials: !rebuildingSnapshot })[0] + }, board) + historyEventCount += chunk.length + lastSerial = chunk[chunk.length - 1].serial ?? snapshot.serial + } + + await getBoardHistory(id, snapshot.serial, updateBoardWithEventChunk).catch(async (error) => { + console.error(error.message) + console.error( + `Error applying board history for snapshot update for board ${id}. Loop index ${i}. Rebooting snapshot. This may be a lossy operation.`, + ) + i = 0 + board = { ...snapshot, items: {}, connections: [] } + rebuildingSnapshot = true + try { + await getFullBoardHistory(id, client, updateBoardWithEventChunk) + } catch (e) { + console.error(`Unable to reboot snapshot, failing at loop index ${i}. Giving up.`) + + // TODO: this board cannot be repaired automatically. We should block usage, or it will be + // and endless loop. Local dev, board ee803db1-f41a-43c6-9e39-83057faace60. + + throw e + } + }) + + const serial = (historyEventCount > 0 ? lastSerial : snapshot.serial) || 0 + const elapsed = new Date().getTime() - started + console.log( + `Loaded board ${id} at serial ${serial} from snapshot at serial ${snapshot.serial} and ${historyEventCount} events after snapshot. Took ${elapsed}ms`, + ) + + if (historyEventCount > 1000 || rebuildingSnapshot /* time to create a new snapshot*/) { + console.log( + `Saving snapshot for board ${id} at serial ${serial}/${snapshot.serial} with ${historyEventCount} new events`, + ) + await saveBoardSnapshot(mkSnapshot(board, serial), client) + } + const accessTokens = ( + await client.query("SELECT token FROM board_api_token WHERE board_id=$1", [id]) + ).rows.map((row) => row.token) + + return { board: { ...board, serial }, accessTokens } + } + }) +} + +export async function createBoard(board: Board): Promise { + await inTransaction(async (client) => { + const result = await client.query("SELECT id FROM board WHERE id=$1", [board.id]) + if (result.rows.length > 0) throw Error("Board already exists: " + board.id) + await client.query(`INSERT INTO board(id, name, content) VALUES ($1, $2, $3)`, [ + board.id, + board.name, + mkSnapshot(board, 0), + ]) + + await updateAccessPolicy(board.id, board.accessPolicy, client) + + if (!isBoardEmpty(board)) { + console.log(`Creating non-empty board ${board.id} -> bootstrapping history`) + const event = mkBootStrapEvent(board.id, board) + storeEventHistoryBundle(board.id, [event], event.serial!, null, client) + } + }) +} + +async function updateAccessPolicy(boardId: string, accessPolicy: BoardAccessPolicy, client: PoolClient): Promise { + const publicRead = accessPolicy ? !!accessPolicy.publicRead : true + const publicWrite = accessPolicy ? !!accessPolicy.publicWrite : true + await client.query(`UPDATE board SET public_read=$1, public_write=$2 WHERE id=$3`, [ + publicRead, + publicWrite, + boardId, + ]) + await client.query(`DELETE FROM board_access WHERE board_id=$1`, [boardId]) + if (accessPolicy) { + for (const entry of accessPolicy.allowList) { + const domain = "domain" in entry ? entry.domain : null + const email = "email" in entry ? entry.email : null + await client.query(`INSERT INTO BOARD_access (board_id, domain, email, access) VALUES ($1, $2, $3, $4)`, [ + boardId, + domain, + email, + entry.access, + ]) + } + } +} + +// Updates the name column, which is there for admin convenience (not used by application logic) +export async function renameBoardConvenienceColumnOnly(boardId: Id, name: string) { + await inTransaction(async (client) => { + const result = await client.query("SELECT content FROM board WHERE id=$1", [boardId]) + if (result.rows.length !== 1) throw Error("Board not found: " + boardId) + await client.query("UPDATE board SET name=$1 WHERE id=$2", [name, boardId]) + }) +} + +export async function updateBoardAccessPolicy(boardId: Id, accessPolicy: BoardAccessPolicy) { + await inTransaction(async (client) => { + const result = await client.query("SELECT content FROM board WHERE id=$1", [boardId]) + if (result.rows.length !== 1) throw Error("Board not found: " + boardId) + await updateAccessPolicy(boardId, accessPolicy, client) + }) +} + +export async function createAccessToken(board: Board): Promise { + const token = uuid.v4() + await inTransaction(async (client) => + client.query("INSERT INTO board_api_token (board_id, token) VALUES ($1, $2)", [board.id, token]), + ) + return token +} + +type StreamingBoardEventCallback = (chunk: BoardHistoryEntry[]) => void + +// Due to memory concerns we fetch board histories from DB as chunks, +// which are currently implemented as sort of a poor-man's observable +function streamingBoardEventsQuery(text: string, values: any[], client: PoolClient, cb: StreamingBoardEventCallback) { + return new Promise((resolve, reject) => { + const query = new QueryStream(text, values) + const stream = client.query(query) + stream.on("error", reject) + stream.on("end", resolve) + stream.on("data", (row) => { + try { + const chunk = row.events?.events as BoardHistoryEntry[] | undefined + + if (!chunk) { + throw Error(`Unexpected DB row value ${chunk}`) + } + + cb(chunk.map(migrateEvent)) + } catch (error) { + console.error(error) + stream.destroy() + reject(error) + } + }) + }) +} + +export function getFullBoardHistory(id: Id, client: PoolClient, cb: StreamingBoardEventCallback) { + return streamingBoardEventsQuery( + ` + SELECT events + FROM board_event + WHERE board_id=$1 + ORDER BY last_serial, first_serial + `, + [id], + client, + cb, + ) +} + +export async function getBoardHistory(id: Id, afterSerial: Serial, cb: StreamingBoardEventCallback): Promise { + await withDBClient(async (client) => { + let firstSerial = -1 + let lastSerial = -1 + let firstValidSerial = -1 + await streamingBoardEventsQuery( + ` + SELECT events + FROM board_event + WHERE board_id=$1 AND last_serial >= $2 + ORDER BY last_serial, first_serial + `, + [id, afterSerial], + client, + (chunk) => { + if (chunk.length === 0) { + return // CRDT-only bundle + } + if (firstSerial === -1 && typeof chunk[0]?.serial === "number") { + firstSerial = chunk[0]?.serial + } + lastSerial = chunk[chunk.length - 1].serial ?? -1 + + const validEventsAfter = chunk.filter((r) => r.serial! > afterSerial) + if (validEventsAfter.length === 0) { + // Got chunk where no events have serial greater than the snapshot point -- discard it + return + } + + if (firstValidSerial === -1 && typeof validEventsAfter[0].serial === "number") { + firstValidSerial = validEventsAfter[0].serial + } + cb(validEventsAfter) + return + }, + ) + + // Client is up to date, ok + if (lastSerial === afterSerial) { + return + } + + // Found continuous history, ok + if (firstValidSerial === afterSerial + 1) { + return + } + + if (firstValidSerial === -1) { + if (afterSerial === 0) { + // Requesting from start, zero events found, is ok + return + } + // Client claims to be in the future, not ok + throw Error( + `Cannot find history to start after the requested serial ${afterSerial} for board ${id}. Seems like the requested serial is higher than currently stored in DB`, + ) + } + + // Found noncontinuous event timeline, not ok + throw Error( + `Cannot find history to start after the requested serial ${afterSerial} for board ${id}. Found history for ${firstValidSerial}..${lastSerial}`, + ) + }) +} + +export function verifyContinuity(boardId: Id, init: Serial, ...histories: BoardHistoryEntry[][]) { + for (let history of histories) { + if (history.length > 0) { + if (!verifyTwoPoints(boardId, init, history[0].serial!)) { + return false + } + init = history[history.length - 1].serial! + } + } + return true +} + +export function verifyEventArrayContinuity(boardId: Id, init: Serial, events: BoardHistoryEntry[]) { + for (let event of events) { + if (!verifyTwoPoints(boardId, init, event.serial!)) { + return false + } + init = event.serial! + } + return true +} + +function verifyTwoPoints(boardId: Id, a: Serial, b: Serial) { + if (b !== a + 1) { + console.error(`History discontinuity: ${a} -> ${b} for board ${boardId}`) + return false + } + return true +} + +function mkSnapshot(board: Board, serial: Serial) { + const { accessPolicy, ...result } = migrateBoard({ ...board, serial }) + return result +} + +export async function saveBoardSnapshot(board: Board, client: PoolClient) { + console.log(`Save board snapshot ${board.id} at serial ${board.serial}`) + client.query( + ` + UPDATE board + SET name=$2, content=$3 + WHERE id=$1`, + [board.id, board.name, board], + ) +} + +export async function storeEventHistoryBundle( + boardId: Id, + events: BoardHistoryEntry[], + lastSerial: Serial, // Needed in case events is empty + crdtUpdate: Uint8Array | null, + client: PoolClient, + savedAt = new Date(), +) { + if (events.length === 0 && crdtUpdate === null) { + throw Error("Trying to store a bundle without events or crdtUpdate") + } + if (events[0]?.firstSerial !== undefined) { + throw Error("Assertion failed: folded events not expected on the server side.") + } + const firstSerial = events.length > 0 ? assertNotNull(events[0].serial) : null + if (events.length > 0 && events[events.length - 1].serial != lastSerial) { + throw Error("Serial mismatch between lastSerial and lastEvent.serial") + } + await client.query( + ` + INSERT INTO board_event(board_id, first_serial, last_serial, events, crdt_update, saved_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [boardId, firstSerial, lastSerial, { events }, crdtUpdate, savedAt], + ) +} + +export async function storeCRDTOnlyEventHistoryBundle( + boardId: Id, + boardSerial: Serial, + crdtUpdate: Uint8Array | null, + client: PoolClient, + savedAt = new Date(), +) { + await client.query( + ` + INSERT INTO board_event(board_id, first_serial, last_serial, events, crdt_update, saved_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, + [boardId, null, boardSerial, null, crdtUpdate, savedAt], + ) +} + +export type BoardHistoryBundle = { + board_id: Id + last_serial: Serial + events: { + events: BoardHistoryEntry[] + } + crdt_update: Uint8Array | null +} + +export async function getBoardHistoryBundlesWithLastSerialsBetween( + client: PoolClient, + id: Id, + lsMin: Serial, + lsMax: Serial, +): Promise { + return ( + await client.query( + ` + SELECT board_id, last_serial, events, crdt_update + FROM board_event + WHERE board_id=$1 AND last_serial >= $2 AND last_serial <= $3 + ORDER BY last_serial, first_serial + `, + [id, lsMin, lsMax], + ) + ).rows.map(migrateBundle) +} + +export async function getBoardHistoryCrdtUpdates(client: PoolClient, id: Id): Promise { + return ( + await client.query( + ` + SELECT crdt_update + FROM board_event + WHERE board_id=$1 AND crdt_update IS NOT NULL + ORDER BY last_serial, first_serial + `, + [id], + ) + ).rows.map((row) => row.crdt_update) +} + +function migrateBundle(b: BoardHistoryBundle): BoardHistoryBundle { + return { ...b, events: { ...b.events, events: b.events.events.map(migrateEvent) } } +} + +export type BoardHistoryBundleMeta = { + board_id: Id + first_serial: Serial | null + last_serial: Serial + saved_at: Date +} + +export async function getBoardHistoryBundleMetas(client: PoolClient, id: Id): Promise { + return ( + await client.query( + ` + SELECT board_id, last_serial, first_serial, saved_at + FROM board_event + WHERE board_id=$1 + ORDER BY last_serial, first_serial + `, + [id], + ) + ).rows +} + +export function verifyContinuityFromMetas(boardId: Id, init: Serial, bundles: BoardHistoryBundleMeta[]) { + for (let bundle of bundles) { + if (!bundle.first_serial) { + // CRDT only bundle + if (bundle.last_serial !== init) { + console.error( + `History discontinuity: ${init} -> ${bundle.last_serial} for CRDT-only bundle for board ${boardId}`, + ) + return false + } + } else { + if (!verifyTwoPoints(boardId, init, bundle.first_serial)) { + return false + } + init = bundle.last_serial + } + } + return true +} + +export async function findAllBoards(client: PoolClient): Promise { + const result = await client.query("SELECT id FROM board") + return result.rows.map((row) => row.id) +} diff --git a/backend/src/board-yjs-server.ts b/backend/src/board-yjs-server.ts new file mode 100644 index 0000000..80cdc48 --- /dev/null +++ b/backend/src/board-yjs-server.ts @@ -0,0 +1,102 @@ +import expressWs from "express-ws" +import * as Y from "yjs" +import { updateBoardCrdt } from "./board-state" +import { getBoardHistoryCrdtUpdates } from "./board-store" +import { withDBClient } from "./db" +import { getSessionIdFromCookies } from "./http-session" +import { getSessionById } from "./websocket-sessions" +import YWebSocketServer from "./y-websocket-server/YWebSocketServer" +import * as WebSocket from "ws" +import { canRead, canWrite } from "../../common/src/domain" + +const socketsBySessionId: Record = {} + +export function closeYjsSocketsBySessionId(sessionId: string) { + const sockets = socketsBySessionId[sessionId] + if (sockets) { + for (const socket of sockets) { + socket.close() + } + delete socketsBySessionId[sessionId] + console.log( + `CLOSED ${sockets.length} y.js sockets by session id ${sessionId} - remaining sockets exist for ${ + Object.keys(socketsBySessionId).length + } other sessions`, + ) + } +} + +export const yWebSocketServer = new YWebSocketServer({ + persistence: { + bindState: async (docName, ydoc) => { + const boardId = docName + const updates = await withDBClient(async (client) => getBoardHistoryCrdtUpdates(client, boardId)) + + if (updates.length === 0) { + const initUpdate = Y.encodeStateAsUpdate(ydoc) + console.log(`Storing initial CRDT state to DB for board ${boardId}`) + updateBoardCrdt(boardId, initUpdate) + } else { + console.log(`Loaded ${updates.length} CRDT updates from DB for board ${boardId}`) + for (const update of updates) { + Y.applyUpdate(ydoc, update) + } + } + + ydoc.on("update", (update: Uint8Array, origin: any, doc: Y.Doc) => { + updateBoardCrdt(boardId, update) + }) + }, + writeState: async (docName, ydoc) => { + // TODO: needed? + }, + }, +}) + +export function BoardYJSServer(ws: expressWs.Instance, path: string) { + ws.app.ws(path, async (socket, req) => { + const boardId = req.params.boardId + const sessionId = getSessionIdFromCookies(req) + const session = sessionId ? getSessionById(sessionId) : undefined + if ( + !sessionId || + !session || + !session.boardSession || + session.boardSession.boardId !== boardId || + !canRead(session.boardSession.accessLevel) + ) { + // TODO: implement read-only YJS connections + //console.warn("No session for YJS connection for board", boardId) + socket.close() + return + } + if (!socketsBySessionId[sessionId]) { + socketsBySessionId[sessionId] = [] + } + socketsBySessionId[sessionId].push(socket) + console.log( + `OPENED y.js connection for session ${sessionId}. Now sockets exist for ${ + Object.keys(socketsBySessionId).length + } sessions`, + ) + socket.addEventListener("close", () => { + if (socketsBySessionId[sessionId]) { + socketsBySessionId[sessionId] = socketsBySessionId[sessionId].filter((s) => s !== socket) + if (socketsBySessionId[sessionId].length === 0) { + delete socketsBySessionId[sessionId] + } + console.log( + `CLOSED y.js connection. Now sockets exist for ${Object.keys(socketsBySessionId).length} sessions`, + ) + } + }) + const readOnly = !canWrite(session.boardSession.accessLevel) + const docName = boardId + try { + await yWebSocketServer.setupWSConnection(socket, docName, readOnly) + } catch (e) { + console.error("Error setting up YJS connection", e) + socket.close() + } + }) +} diff --git a/backend/src/common-event-handler.ts b/backend/src/common-event-handler.ts new file mode 100644 index 0000000..851232b --- /dev/null +++ b/backend/src/common-event-handler.ts @@ -0,0 +1,114 @@ +import * as Y from "yjs" +import { AppEvent, Board, CrdtEnabled, checkBoardAccess, defaultBoardSize } from "../../common/src/domain" +import { addBoard } from "./board-state" +import { fetchBoard } from "./board-store" +import { yWebSocketServer } from "./board-yjs-server" +import { MessageHandlerResult } from "./connection-handler" +import { getAuthenticatedUserFromJWT } from "./http-session" +import { + associateUserWithBoard, + dissociateUserWithBoard, + getUserAssociatedBoards, + getUserIdForEmail, +} from "./user-store" +import { getSession, logoutUser, setNicknameForSession, setVerifiedUserForSession } from "./websocket-sessions" +import { WsWrapper, toBuffer } from "./ws-wrapper" + +export async function handleCommonEvent(socket: WsWrapper, appEvent: AppEvent): Promise { + switch (appEvent.action) { + case "auth.login.jwt": { + const user = getAuthenticatedUserFromJWT(appEvent.jwt) + const session = getSession(socket) + if (session && user !== null) { + const userId = await getUserIdForEmail(user.email) + const userInfo = await setVerifiedUserForSession(user, session) + console.log(`${user.name} logged in`) + session.sendEvent({ action: "auth.login.response", success: true, userId }) + if (session.boardSession) { + await associateUserWithBoard(userId, session.boardSession.boardId) + } + session.sendEvent({ + action: "user.boards", + email: user.email, + boards: await getUserAssociatedBoards(userInfo), + }) + } else if (session) { + session.sendEvent({ action: "auth.login.response", success: false }) + } + return true + } + case "auth.logout": { + const session = getSession(socket) + if (session && session.userInfo.userType === "authenticated") { + logoutUser(appEvent, socket) + console.log(`${session.userInfo.name} logged out`) + } + socket.close() + return true + } + case "nickname.set": { + setNicknameForSession(appEvent, socket) + return true + } + case "board.associate": { + // TODO: maybe access check? Not security-wise necessary + const session = getSession(socket) + if (session) { + if (session.userInfo.userType !== "authenticated") { + console.warn("Trying to associate board without authenticated user") + return true + } + const userId = session.userInfo.userId + await associateUserWithBoard(userId, appEvent.boardId, appEvent.lastOpened) + } + return true + } + case "board.dissociate": { + const session = getSession(socket) + if (session) { + if (session.userInfo.userType !== "authenticated") { + console.warn("Trying to dissociate board without authenticated user") + return true + } + const userId = session.userInfo.userId + await dissociateUserWithBoard(userId, appEvent.boardId) + } + return true + } + case "board.add": { + const session = getSession(socket) + if (session) { + const { payload } = appEvent + let template: Board | null = null + if ("templateId" in payload && payload.templateId) { + const aliased = process.env[`BOARD_ALIAS_${payload.templateId}`] + const templateId = aliased || payload.templateId + const found = await fetchBoard(templateId) + if (found) { + const accessLevel = checkBoardAccess(found.board.accessPolicy, session.userInfo) + if (accessLevel === "none") { + console.warn(`Trying to use board ${found.board.id} as template, without board permissions`) + return true + } + template = { ...found.board, accessPolicy: undefined } + } else { + console.error(`Template ${payload.templateId}${aliased ? `(${templateId})` : ""} not found`) + } + } + const board = { ...defaultBoardSize, items: {}, connections: [], ...template, ...payload, serial: 0 } + if (template && template.crdt === CrdtEnabled) { + const templateDoc = await yWebSocketServer.docs.getYDocAndWaitForFetch(template.id) + const newDoc = await yWebSocketServer.docs.getYDocAndWaitForFetch(board.id) + Y.applyUpdate(newDoc, Y.encodeStateAsUpdate(templateDoc)) + } + await addBoard(board) + socket.send(toBuffer({ action: "board.add.ack", boardId: board.id })) + } + return true + } + case "ping": { + return true + } + } + return false +} diff --git a/backend/src/compact-history.ts b/backend/src/compact-history.ts new file mode 100644 index 0000000..ff0cee0 --- /dev/null +++ b/backend/src/compact-history.ts @@ -0,0 +1,116 @@ +import { format } from "date-fns" +import _ from "lodash" +import { BoardHistoryEntry, Id } from "../../common/src/domain" +import { + BoardHistoryBundleMeta, + getBoardHistoryBundleMetas, + getBoardHistoryBundlesWithLastSerialsBetween, + storeEventHistoryBundle, + verifyContinuity, + verifyContinuityFromMetas, + verifyEventArrayContinuity, +} from "./board-store" +import * as Y from "yjs" +import { inTransaction } from "./db" + +function chunkBy(arr: T[], shouldSplit: (a: T, b: T) => boolean) { + const result = [] + let currentChunk = [] + for (let i = 0; i < arr.length; i++) { + if (i > 0 && shouldSplit(arr[i - 1], arr[i])) { + result.push(currentChunk) + currentChunk = [] + } + currentChunk.push(arr[i]) + } + result.push(currentChunk) + return result +} +function getHour(b: BoardHistoryBundleMeta) { + return format(new Date(b.saved_at), "yyyy-MM-dd hh") +} + +export async function quickCompactBoardHistory(id: Id): Promise { + try { + return await inTransaction(async (client) => { + // Lock the board to prevent loading the board while compacting + await client.query("select 1 from board where id=$1 for update", [id]) + const bundleMetas = await getBoardHistoryBundleMetas(client, id) + if (bundleMetas.length === 0) return 0 + const consistent = verifyContinuityFromMetas(id, 0, bundleMetas) + if (consistent) { + // Group in one-hour bundles + //console.log("Grouped by date", groupedByHour) + const toCompact = chunkBy( + bundleMetas, + (a, b) => getHour(a) !== getHour(b) && a.last_serial !== b.last_serial, + ).filter((chunk) => chunk.length > 1) + let compactions = 0 + for (let bs of toCompact) { + const firstBundle = bs[0] + const lastBundle = bs[bs.length - 1] + console.log( + `Compacting ${bs.length} bundles into one for board ${id}, containing serials ${firstBundle.first_serial}...${lastBundle.last_serial}`, + ) + const lastSerial = lastBundle.last_serial + const bundlesWithData = await getBoardHistoryBundlesWithLastSerialsBetween( + client, + id, + firstBundle.last_serial, + lastSerial, + ) + const eventArrays = bundlesWithData.map((b) => b.events.events) + const events: BoardHistoryEntry[] = eventArrays.flat() + const crdtUpdates = bundlesWithData.flatMap((d) => (d.crdt_update ? [d.crdt_update] : [])) + const combinedCrdtUpdate = crdtUpdates.length ? Y.mergeUpdates(crdtUpdates) : null + const initSerial = firstBundle.first_serial + ? firstBundle.first_serial - 1 + : firstBundle.last_serial - 1 + const consistent = + verifyContinuity(id, initSerial, ...eventArrays) && + verifyEventArrayContinuity(id, initSerial, events) + if (consistent && bundlesWithData.length == bs.length) { + // 1. delete existing bundles + const deleteResult = await client.query( + `DELETE FROM board_event where board_id=$1 and last_serial in (${bundlesWithData + .map((b) => b.last_serial) + .join(",")})`, + [id], + ) + if (deleteResult.rowCount != bs.length) { + throw Error( + `Unexpected rowcount when deleting on compaction: ${deleteResult.rowCount} for board ${id}`, + ) + } + // 2. store as a single bundle + await storeEventHistoryBundle( + id, + events, + lastSerial, + combinedCrdtUpdate, + client, + lastBundle.saved_at, + ) + } else { + throw Error("Discontinuity detected in compacted history.") + } + compactions++ + } + if (compactions > 0) { + } else { + console.log( + `Board ${id}: Verified ${bundleMetas.length} bundles containing ${ + bundleMetas[bundleMetas.length - 1].last_serial + } events => no need to compact`, + ) + } + return compactions + } else { + throw Error("Discontinuity detected in bundle metadata.") + } + }) + } catch (e) { + console.error(`Aborting compaction of board ${id} because of an error: ${e}`) + return 0 + } +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..cdf2a70 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,39 @@ +import path from "path" +import fs from "fs" +import { authProvider } from "./oauth" +import * as t from "io-ts" +import { optional } from "../../common/src/domain" +import { decodeOrThrow } from "./decodeOrThrow" + +export type StorageBackend = Readonly< + { type: "LOCAL"; directory: string; assetStorageURL: string } | { type: "AWS"; assetStorageURL: string } +> +export type Config = Readonly<{ storageBackend: StorageBackend; authSupported: boolean; crdt: CrdtConfigString }> + +const CrdtConfigString = t.union([ + t.literal("opt-in"), + t.literal("opt-in-authenticated"), + t.literal("true"), + t.literal("false"), +]) +export type CrdtConfigString = t.TypeOf + +export const getConfig = (): Config => { + const storageBackend: StorageBackend = process.env.AWS_ASSETS_BUCKET_URL + ? { type: "AWS", assetStorageURL: process.env.AWS_ASSETS_BUCKET_URL } + : { type: "LOCAL", directory: path.resolve("localfiles"), assetStorageURL: "/assets" } + + if (storageBackend.type === "LOCAL") { + try { + fs.mkdirSync(storageBackend.directory) + } catch (e) {} + } + + const crdt = decodeOrThrow(CrdtConfigString, process.env.COLLABORATIVE_EDITING ?? "opt-in") + + return { + storageBackend, + authSupported: authProvider !== null, + crdt, + } +} diff --git a/backend/src/connection-handler.ts b/backend/src/connection-handler.ts new file mode 100644 index 0000000..c1e4577 --- /dev/null +++ b/backend/src/connection-handler.ts @@ -0,0 +1,68 @@ +import { AppEvent, Id, Serial, EventWrapper } from "../../common/src/domain" +import { getActiveBoards } from "./board-state" +import { getConfig } from "./config" +import { releaseLocksFor } from "./locker" +import { broadcastCursorPositions, endSession, startSession } from "./websocket-sessions" +import { WsWrapper, toBuffer } from "./ws-wrapper" + +export type ConnectionHandlerParams = Readonly<{ + getSignedPutUrl: (key: string) => string +}> + +export const connectionHandler = (socket: WsWrapper, handleMessage: MessageHandler) => { + startSession(socket) + const config = getConfig() + socket.send( + toBuffer({ + action: "server.config", + assetStorageURL: config.storageBackend.assetStorageURL, + authSupported: config.authSupported, + crdt: config.crdt, + }), + ) + socket.onError(() => { + socket.close() + }) + socket.onMessage(async (o: object) => { + try { + let event = o as EventWrapper + let serialsToAck: Record = {} + for (const e of event.events) { + const serialAck = await handleMessage(socket, e) + if (serialAck === true) { + } else if (serialAck === false) { + console.warn("Unhandled app-event message", e) + } else { + serialsToAck[serialAck.boardId] = serialAck.serial + } + } + if (event.ackId) { + socket.send(toBuffer({ action: "ack", ackId: event.ackId, serials: serialsToAck })) + } + } catch (e) { + console.error("Error while handling event from client. Closing connection.", e) + socket.close() + } + }) + + socket.onClose(() => { + endSession(socket) + getActiveBoards().forEach((state) => { + delete state.cursorPositions[socket.id] + state.cursorsMoved = true + }) + releaseLocksFor(socket) + }) +} + +setInterval(() => { + getActiveBoards().forEach((bh) => { + if (bh.cursorsMoved) { + broadcastCursorPositions(bh.board.id, bh.cursorPositions) + bh.cursorsMoved = false + } + }) +}, 100) + +export type MessageHandler = (socket: WsWrapper, appEvent: AppEvent) => Promise +export type MessageHandlerResult = { boardId: Id; serial: Serial } | boolean diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..f39c1b9 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,72 @@ +import pg, { PoolClient } from "pg" +import process from "process" +import migrate from "node-pg-migrate" + +const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://r-board:secret@127.0.0.1:13338/r-board" +const DATABASE_SSL_ENABLED = process.env.DATABASE_SSL_ENABLED === "true" + +const pgConfig = { + connectionString: DATABASE_URL, + ssl: DATABASE_SSL_ENABLED + ? { + rejectUnauthorized: false, + } + : undefined, +} +const connectionPool = new pg.Pool(pgConfig) + +export function closeConnectionPool() { + connectionPool.end() +} + +export async function initDB(backendDir: string = ".") { + console.log("Running database migrations") + await inTransaction((client) => + migrate({ + count: 100000, + databaseUrl: DATABASE_URL, + migrationsTable: "pgmigrations", + dir: `${backendDir}/migrations`, + direction: "up", + dbClient: client, + }), + ) + console.log("Completed database migrations") + + return { + onEvent: async (eventNames: string[], cb: (n: pg.Notification) => any) => { + const client = await connectionPool.connect() + eventNames.map((e) => client.query(`LISTEN ${e}`)) + client.on("notification", cb) + }, + } +} + +export async function withDBClient(f: (client: PoolClient) => Promise): Promise { + const client = await connectionPool.connect() + try { + await client.query("BEGIN;SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;") + return await f(client) + } finally { + await client.query("ROLLBACK;") + client.release() + } +} + +export async function inTransaction(f: (client: PoolClient) => Promise): Promise { + const client = await connectionPool.connect() + try { + await client.query(` + BEGIN; + SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE; + `) + const result = await f(client) + await client.query("COMMIT;") + return result + } catch (e) { + await client.query("ROLLBACK;") + throw e + } finally { + client.release() + } +} diff --git a/backend/src/decodeOrThrow.ts b/backend/src/decodeOrThrow.ts new file mode 100644 index 0000000..89b4287 --- /dev/null +++ b/backend/src/decodeOrThrow.ts @@ -0,0 +1,21 @@ +import * as t from "io-ts" +import { Left, isLeft, left } from "fp-ts/lib/Either" +import { PathReporter } from "io-ts/lib/PathReporter" + +export function decodeOrThrow(codec: t.Type, input: any): T { + const validationResult = codec.decode(input) + if (isLeft(validationResult)) { + throw new ValidationError(validationResult) + } + return validationResult.right +} + +class ValidationError extends Error { + constructor(errors: Left) { + super(report_(errors.left)) + } +} + +function report_(errors: t.Errors) { + return PathReporter.report(left(errors)).join("\n") +} diff --git a/backend/src/env.ts b/backend/src/env.ts new file mode 100644 index 0000000..aac145b --- /dev/null +++ b/backend/src/env.ts @@ -0,0 +1,7 @@ +import process from "process" + +export function getEnv(name: string): string { + const value = process.env[name] + if (!value) throw new Error("Missing ENV: " + name) + return value +} diff --git a/backend/src/expiring-map.ts b/backend/src/expiring-map.ts new file mode 100644 index 0000000..9ef4b9f --- /dev/null +++ b/backend/src/expiring-map.ts @@ -0,0 +1,53 @@ +export function AutoExpiringMap(ttlSeconds: number) { + const timers = new Map() + const data: Record = {} + const listeners: ((v: Record) => any)[] = [] + const proxy = new Proxy(data, { + set(target, key, value) { + if (typeof key === "symbol") return false + target[key] = value + setExpiryTimer(key) + listeners.forEach((l) => l(target)) + return true + }, + deleteProperty(target, key) { + if (typeof key === "symbol") return false + const didDelete = delete target[key] + if (!didDelete) { + return false + } + listeners.forEach((l) => l(target)) + return true + }, + }) + + const setExpiryTimer = (key: string | number) => { + if (timers.has(key)) { + clearTimeout(timers.get(key)!) + } + + timers.set( + key, + setTimeout(() => { + timers.delete(key) + delete proxy[key] + }, ttlSeconds * 1000), + ) + } + + const autoExpiringMap = { + get: (key: string) => proxy[key], + has: (key: string) => !!proxy[key], + entries: () => Object.entries(proxy), + delete: (key: string) => delete proxy[key], + set: (key: string, value: any) => { + proxy[key] = value + }, + onChange: (fn: (v: Record) => any) => { + listeners.push(fn) + return autoExpiringMap + }, + } + + return autoExpiringMap +} diff --git a/backend/src/express-server.ts b/backend/src/express-server.ts new file mode 100644 index 0000000..6363915 --- /dev/null +++ b/backend/src/express-server.ts @@ -0,0 +1,162 @@ +import dotenv from "dotenv" +import express from "express" +import expressWs from "express-ws" +import fs from "fs" +import * as Http from "http" +import * as Https from "https" +import * as path from "path" + +import apiRoutes from "./api/api-routes" +import { handleBoardEvent } from "./board-event-handler" +import { BoardYJSServer } from "./board-yjs-server" +import { getConfig } from "./config" +import { connectionHandler } from "./connection-handler" +import { getEnv } from "./env" +import { authProvider, setupAuth } from "./oauth" + +import { possiblyRequireAuth } from "./require-auth" +import { createGetSignedPutUrl } from "./storage" +import { WsWrapper } from "./ws-wrapper" +import Cookies from "cookies" +import { removeAuthenticatedUser, setAuthenticatedUser } from "./http-session" + +dotenv.config() + +export const startExpressServer = (httpPort?: number, httpsPort?: number): (() => void) => { + const config = getConfig() + + const app = express() + + if (authProvider) { + setupAuth(app, authProvider) + } else { + app.get("/logout", async (req, res) => { + removeAuthenticatedUser(req, res) + res.redirect("/") + }) + } + app.get("/test-callback", async (req, res) => { + const cookies = new Cookies(req, res) + const returnTo = cookies.get("returnTo") || "/" + setAuthenticatedUser(req, res, { domain: null, email: "ourboardtester@test.com", name: "Ourboard tester" }) + res.redirect(returnTo) + }) + + possiblyRequireAuth(app) + + app.use("/", express.static("../frontend/dist")) + app.use("/", express.static("../frontend/public")) + + if (config.storageBackend.type === "LOCAL") { + const localDirectory = config.storageBackend.directory + app.put("/assets/:id", (req, res) => { + if (!req.params.id) { + return res.sendStatus(400) + } + + const w = fs.createWriteStream(localDirectory + "/" + req.params.id) + + req.pipe(w) + + req.on("end", () => { + !res.headersSent && res.sendStatus(200) + }) + + w.on("error", () => { + res.sendStatus(500) + }) + }) + app.use("/assets", express.static(localDirectory)) + } + + app.get("/assets/external", (req, res) => { + const src = req.query.src + if (typeof src !== "string" || ["http://", "https://"].every((prefix) => !src.startsWith(prefix))) + return res.send(400) + const protocol = src.startsWith("https://") ? Https : Http + + protocol + .request(src, (upstreamResponse) => { + res.writeHead(upstreamResponse.statusCode!, upstreamResponse.headers) + upstreamResponse + .pipe(res, { + end: true, + }) + .on("error", (err) => res.status(500).send(err.message)) + }) + .end() + }) + + app.get("/b/:boardId", async (req, res) => { + res.sendFile(path.resolve("../frontend/dist/index.html")) + }) + + app.use(apiRoutes.handler()) + + let stop = () => {} + + if (httpPort) { + const http = new Http.Server(app) + startWs(http, app) + http.listen(httpPort, () => { + console.log("Listening HTTP on port " + httpPort) + }) + const prevStop = stop + stop = () => { + prevStop() + http.close() + } + } + + if (httpsPort) { + let https = new Https.Server( + { + cert: fs.readFileSync(getEnv("HTTPS_CERT_FILE")), + key: fs.readFileSync(getEnv("HTTPS_KEY_FILE")), + }, + app, + ) + startWs(https, app) + https.listen(httpsPort, () => { + console.log("Listening HTTPS on port " + httpsPort) + }) + const prevStop = stop + stop = () => { + prevStop() + https.close() + } + } + + const redirectURL = process.env.REDIRECT_URL + if (redirectURL) { + app.get("*", function (req, res, next) { + if (req.headers["x-forwarded-proto"] !== "https") { + res.redirect(redirectURL) + } else { + next() + } + }) + } + return stop +} + +function startWs(http: any, app: express.Express) { + const ws: expressWs.Instance = expressWs(app, http) + + const signedPutUrl = createGetSignedPutUrl(getConfig().storageBackend) + + ws.app.ws("/socket/lobby", (socket, req) => { + connectionHandler(WsWrapper(socket), handleBoardEvent(null, signedPutUrl)) + }) + ws.app.ws("/socket/board/:boardId", (socket, req) => { + const boardId = req.params.boardId + connectionHandler(WsWrapper(socket), handleBoardEvent(boardId, signedPutUrl)) + }) + + BoardYJSServer(ws, "/socket/yjs/board/:boardId/") + + ws.app.ws("*", (socket, req) => { + console.warn(`Unexpected WS connection: ${req.url} `) + socket.close() + }) +} diff --git a/backend/src/generic-oidc-auth.ts b/backend/src/generic-oidc-auth.ts new file mode 100644 index 0000000..655f0aa --- /dev/null +++ b/backend/src/generic-oidc-auth.ts @@ -0,0 +1,123 @@ +import { Request, Response } from "express" +import * as t from "io-ts" +import JWT from "jsonwebtoken" +import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" +import { optional } from "../../common/src/domain" +import { decodeOrThrow } from "./decodeOrThrow" +import { getEnv } from "./env" +import { ROOT_URL } from "./host-config" +import { AuthProvider } from "./oauth" +import { REQUIRE_AUTH } from "./require-auth" + +type GenericOAuthConfig = { + OIDC_CONFIG_URL: string + OIDC_CLIENT_ID: string + OIDC_CLIENT_SECRET: string + OIDC_LOGOUT?: string +} + +export const genericOIDCConfig: GenericOAuthConfig | null = process.env.OIDC_CONFIG_URL + ? { + OIDC_CONFIG_URL: getEnv("OIDC_CONFIG_URL"), + OIDC_CLIENT_ID: getEnv("OIDC_CLIENT_ID"), + OIDC_CLIENT_SECRET: getEnv("OIDC_CLIENT_SECRET"), + OIDC_LOGOUT: process.env.OIDC_LOGOUT, + } + : null + +export function GenericOIDCAuthProvider(config: GenericOAuthConfig): AuthProvider { + console.log(`Setting up generic OAuth authentication using client id ${config.OIDC_CLIENT_ID}`) + + const callbackUrl = `${ROOT_URL}/google-callback` + + const openIdConfiguration = (async () => { + const response = await fetch(config.OIDC_CONFIG_URL) + return decodeOrThrow(OpenIdConfiguration, await response.json()) + })() + + async function getAccountFromCode(code: string): Promise { + const response = await fetch((await openIdConfiguration).token_endpoint, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: `grant_type=authorization_code&code=${encodeURIComponent(code)}&client_id=${encodeURIComponent( + config.OIDC_CLIENT_ID, + )}&client_secret=${config.OIDC_CLIENT_SECRET}&redirect_uri=${callbackUrl}`, + }) + + const body = await response.json() + + const idToken = JWT.decode(body.id_token) + //console.log(JSON.stringify(idToken, null, 2)) + const user = decodeOrThrow(IdToken, idToken) + return { + email: user.email, + name: "name" in user ? user.name : user.preferred_username, + picture: user.picture ?? undefined, + domain: user.hd ?? null, + } + } + + async function getAuthPageURL() { + const scopes = "email openid profile" + const state = "TODO" + const redirectUri = callbackUrl + return `${(await openIdConfiguration).authorization_endpoint}?scope=${encodeURIComponent( + scopes, + )}&response_type=code&state=${encodeURIComponent(state)}&redirect_uri=${encodeURIComponent( + redirectUri, + )}&client_id=${config.OIDC_CLIENT_ID}` + } + + const shouldHandleLogout = REQUIRE_AUTH || config.OIDC_LOGOUT + + async function getLogoutUrl(): Promise { + if (config.OIDC_LOGOUT && config.OIDC_LOGOUT !== "true") { + return config.OIDC_LOGOUT + } + + const logoutUrl = (await openIdConfiguration).end_session_endpoint + + if (!logoutUrl) { + throw Error( + `OIDC configuration at ${config.OIDC_CONFIG_URL} does not specify end_session_endpoint. Use OIDC_LOGOUT environment variable to define the logout endpoint explicitly.`, + ) + } + + return logoutUrl + } + + const logout = shouldHandleLogout + ? async (req: Request, res: Response) => { + res.redirect(await getLogoutUrl()) + } + : undefined + + return { + getAccountFromCode, + getAuthPageURL, + logout, + } +} + +const OpenIdConfiguration = t.type({ + authorization_endpoint: t.string, + token_endpoint: t.string, + end_session_endpoint: optional(t.string), +}) + +const IdToken = t.union([ + t.type({ + email: t.string, + name: t.string, + picture: optional(t.string), + hd: optional(t.string), + }), + t.type({ + email: t.string, + preferred_username: t.string, + picture: optional(t.string), + hd: optional(t.string), + }), +]) diff --git a/backend/src/github-webhook/example-payload.json b/backend/src/github-webhook/example-payload.json new file mode 100644 index 0000000..0ef5b20 --- /dev/null +++ b/backend/src/github-webhook/example-payload.json @@ -0,0 +1,239 @@ +{ + "action": "assigned", + "issue": { + "url": "https://api.github.com/repos/raimohanska/r-board/issues/129", + "repository_url": "https://api.github.com/repos/raimohanska/r-board", + "labels_url": "https://api.github.com/repos/raimohanska/r-board/issues/129/labels{/name}", + "comments_url": "https://api.github.com/repos/raimohanska/r-board/issues/129/comments", + "events_url": "https://api.github.com/repos/raimohanska/r-board/issues/129/events", + "html_url": "https://github.com/raimohanska/r-board/issues/129", + "id": 810436967, + "node_id": "MDU6SXNzdWU4MTA0MzY5Njc=", + "number": 129, + "title": "Github issues integration", + "user": { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + }, + "labels": [ + { + "id": 2438308116, + "node_id": "MDU6TGFiZWwyNDM4MzA4MTE2", + "url": "https://api.github.com/repos/raimohanska/r-board/labels/enhancement", + "name": "bug", + "color": "a2eeef", + "default": true, + "description": "Bug" + } + ], + "state": "open", + "locked": false, + "assignee": { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + }, + "assignees": [ + { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + } + ], + "milestone": null, + "comments": 0, + "created_at": "2021-02-17T18:39:11Z", + "updated_at": "2021-02-17T19:08:21Z", + "closed_at": null, + "author_association": "OWNER", + "active_lock_reason": null, + "body": "", + "performed_via_github_app": null + }, + "assignee": { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + }, + "repository": { + "id": 305431036, + "node_id": "MDEwOlJlcG9zaXRvcnkzMDU0MzEwMzY=", + "name": "r-board", + "full_name": "raimohanska/r-board", + "private": false, + "owner": { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/raimohanska/r-board", + "description": "An online whiteboard", + "fork": false, + "url": "https://api.github.com/repos/raimohanska/r-board", + "forks_url": "https://api.github.com/repos/raimohanska/r-board/forks", + "keys_url": "https://api.github.com/repos/raimohanska/r-board/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/raimohanska/r-board/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/raimohanska/r-board/teams", + "hooks_url": "https://api.github.com/repos/raimohanska/r-board/hooks", + "issue_events_url": "https://api.github.com/repos/raimohanska/r-board/issues/events{/number}", + "events_url": "https://api.github.com/repos/raimohanska/r-board/events", + "assignees_url": "https://api.github.com/repos/raimohanska/r-board/assignees{/user}", + "branches_url": "https://api.github.com/repos/raimohanska/r-board/branches{/branch}", + "tags_url": "https://api.github.com/repos/raimohanska/r-board/tags", + "blobs_url": "https://api.github.com/repos/raimohanska/r-board/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/raimohanska/r-board/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/raimohanska/r-board/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/raimohanska/r-board/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/raimohanska/r-board/statuses/{sha}", + "languages_url": "https://api.github.com/repos/raimohanska/r-board/languages", + "stargazers_url": "https://api.github.com/repos/raimohanska/r-board/stargazers", + "contributors_url": "https://api.github.com/repos/raimohanska/r-board/contributors", + "subscribers_url": "https://api.github.com/repos/raimohanska/r-board/subscribers", + "subscription_url": "https://api.github.com/repos/raimohanska/r-board/subscription", + "commits_url": "https://api.github.com/repos/raimohanska/r-board/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/raimohanska/r-board/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/raimohanska/r-board/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/raimohanska/r-board/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/raimohanska/r-board/contents/{+path}", + "compare_url": "https://api.github.com/repos/raimohanska/r-board/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/raimohanska/r-board/merges", + "archive_url": "https://api.github.com/repos/raimohanska/r-board/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/raimohanska/r-board/downloads", + "issues_url": "https://api.github.com/repos/raimohanska/r-board/issues{/number}", + "pulls_url": "https://api.github.com/repos/raimohanska/r-board/pulls{/number}", + "milestones_url": "https://api.github.com/repos/raimohanska/r-board/milestones{/number}", + "notifications_url": "https://api.github.com/repos/raimohanska/r-board/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/raimohanska/r-board/labels{/name}", + "releases_url": "https://api.github.com/repos/raimohanska/r-board/releases{/id}", + "deployments_url": "https://api.github.com/repos/raimohanska/r-board/deployments", + "created_at": "2020-10-19T15:33:11Z", + "updated_at": "2021-02-17T18:39:38Z", + "pushed_at": "2021-02-17T18:31:01Z", + "git_url": "git://github.com/raimohanska/r-board.git", + "ssh_url": "git@github.com:raimohanska/r-board.git", + "clone_url": "https://github.com/raimohanska/r-board.git", + "svn_url": "https://github.com/raimohanska/r-board", + "homepage": null, + "size": 1137, + "stargazers_count": 4, + "watchers_count": 4, + "language": "TypeScript", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "forks_count": 2, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 33, + "license": { + "key": "other", + "name": "Other", + "spdx_id": "NOASSERTION", + "url": null, + "node_id": "MDc6TGljZW5zZTA=" + }, + "forks": 2, + "open_issues": 33, + "watchers": 4, + "default_branch": "master" + }, + "sender": { + "login": "raimohanska", + "id": 292964, + "node_id": "MDQ6VXNlcjI5Mjk2NA==", + "avatar_url": "https://avatars.githubusercontent.com/u/292964?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/raimohanska", + "html_url": "https://github.com/raimohanska", + "followers_url": "https://api.github.com/users/raimohanska/followers", + "following_url": "https://api.github.com/users/raimohanska/following{/other_user}", + "gists_url": "https://api.github.com/users/raimohanska/gists{/gist_id}", + "starred_url": "https://api.github.com/users/raimohanska/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/raimohanska/subscriptions", + "organizations_url": "https://api.github.com/users/raimohanska/orgs", + "repos_url": "https://api.github.com/users/raimohanska/repos", + "events_url": "https://api.github.com/users/raimohanska/events{/privacy}", + "received_events_url": "https://api.github.com/users/raimohanska/received_events", + "type": "User", + "site_admin": false + } +} diff --git a/backend/src/google-auth.ts b/backend/src/google-auth.ts new file mode 100644 index 0000000..0225d70 --- /dev/null +++ b/backend/src/google-auth.ts @@ -0,0 +1,70 @@ +import { google } from "googleapis" +import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" +import { assertNotNull } from "../../common/src/assertNotNull" +import { getEnv } from "./env" +import { AuthProvider } from "./oauth" +import { ROOT_URL } from "./host-config" +import { decodeOrThrow } from "./decodeOrThrow" +import * as T from "io-ts" +import { optional } from "../../common/src/domain" +import JWT from "jsonwebtoken" + +type GoogleConfig = { + clientID: string + clientSecret: string + callbackURL: string +} + +export const googleConfig: GoogleConfig | null = process.env.GOOGLE_OAUTH_CLIENT_ID + ? { + clientID: getEnv("GOOGLE_OAUTH_CLIENT_ID"), + clientSecret: getEnv("GOOGLE_OAUTH_CLIENT_SECRET"), + callbackURL: `${ROOT_URL}/google-callback`, + } + : null + +export const GoogleAuthProvider = (googleConfig: GoogleConfig): AuthProvider => { + console.log(`Setting up Google authentication using client ID ${googleConfig.clientID}`) + + const googleScopes = ["email", "https://www.googleapis.com/auth/userinfo.profile"] + + function googleOAUTH2() { + if (!googleConfig.clientID || !googleConfig.clientSecret) + throw new Error("Missing environment variables for Google OAuth") + return new google.auth.OAuth2(googleConfig.clientID, googleConfig.clientSecret, googleConfig.callbackURL) + } + + async function getAuthPageURL() { + return googleOAUTH2().generateAuthUrl({ + scope: googleScopes, + prompt: "select_account", + }) + } + + const IdToken = T.strict({ + hd: optional(T.string), + email: T.string, + email_verified: T.boolean, + name: T.string, + picture: optional(T.string), + }) + + async function getAccountFromCode(code: string): Promise { + const auth = googleOAUTH2() + const data = await auth.getToken(code) + const idToken = decodeOrThrow(IdToken, JWT.decode(assertNotNull(data.tokens.id_token))) + const email = idToken.email + + return { + name: idToken.name, + email, + picture: idToken.picture ?? undefined, + domain: idToken.hd ?? null, + } + } + + return { + getAuthPageURL, + getAccountFromCode, + } +} diff --git a/backend/src/host-config.ts b/backend/src/host-config.ts new file mode 100644 index 0000000..6b5fa51 --- /dev/null +++ b/backend/src/host-config.ts @@ -0,0 +1,6 @@ +export const ROOT_URL = process.env.ROOT_URL ?? "http://localhost:1337" +export const ROOT_HOST = new URL(ROOT_URL).host +export const ROOT_PROTOCOL = new URL(ROOT_URL).protocol +export const WS_HOST_LOCAL = (process.env.WS_HOST_LOCAL ?? ROOT_HOST).split(",") +export const WS_HOST_DEFAULT = process.env.WS_HOST_DEFAULT ?? ROOT_HOST +export const WS_PROTOCOL = process.env.WS_PROTOCOL ?? (ROOT_PROTOCOL.startsWith("https") ? "wss" : "ws") diff --git a/backend/src/http-session.ts b/backend/src/http-session.ts new file mode 100644 index 0000000..fd114e5 --- /dev/null +++ b/backend/src/http-session.ts @@ -0,0 +1,53 @@ +import Cookies from "cookies" +import { IncomingMessage, ServerResponse } from "http" +import JWT from "jsonwebtoken" +import { getEnv } from "./env" +import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" +import { ISOTimeStamp, newISOTimeStamp } from "../../common/src/domain" + +const secret = getEnv("SESSION_SIGNING_SECRET") + +export type LoginInfo = OAuthAuthenticatedUser & { + timestamp: ISOTimeStamp | undefined +} + +export function getSessionIdFromCookies(req: IncomingMessage): string | null { + return new Cookies(req, null as any).get("sessionId") ?? null +} + +// Get / set authenticated user stored in cookies +export function getAuthenticatedUser(req: IncomingMessage): LoginInfo | null { + const userCookie = new Cookies(req, null as any).get("user") + if (userCookie) { + return getAuthenticatedUserFromJWT(userCookie) + } + return null +} + +export function getAuthenticatedUserFromJWT(jwt: string): LoginInfo | null { + try { + JWT.verify(jwt, secret) + const loginInfo = JWT.decode(jwt) as LoginInfo + if (loginInfo.domain === undefined) { + console.log("Rejecting legacy token without domain") + return null + } + return loginInfo + } catch (e) { + console.warn("Token verification failed", jwt, e) + } + return null +} + +export function setAuthenticatedUser(req: IncomingMessage, res: ServerResponse, userInfo: OAuthAuthenticatedUser) { + const loginInfo: LoginInfo = { ...userInfo, timestamp: newISOTimeStamp() } + const jwt = JWT.sign(loginInfo, secret) + new Cookies(req, res).set("user", jwt, { + maxAge: 365 * 24 * 3600 * 1000, + httpOnly: false, + }) // Max 365 days expiration +} + +export function removeAuthenticatedUser(req: IncomingMessage, res: ServerResponse) { + new Cookies(req, res).set("user", "", { maxAge: 0, httpOnly: true }) +} diff --git a/backend/src/locker.ts b/backend/src/locker.ts new file mode 100644 index 0000000..3001847 --- /dev/null +++ b/backend/src/locker.ts @@ -0,0 +1,58 @@ +import { Id, BoardItemEvent, isPersistableBoardItemEvent, getItemIds, ItemLocks } from "../../common/src/domain" +import { getActiveBoards, ServerSideBoardState } from "./board-state" +import { AutoExpiringMap } from "./expiring-map" +import { WsWrapper } from "./ws-wrapper" + +const LOCK_TTL_SECONDS = 10 + +export function Locks(onChange: (locks: ItemLocks) => any) { + const locks = AutoExpiringMap(LOCK_TTL_SECONDS).onChange(onChange) + + return { + lockItem: (itemId: Id, sessionId: Id) => { + if (locks.has(itemId) && locks.get(itemId) !== sessionId) { + return false + } + + locks.set(itemId, sessionId) + return true + }, + unlockItem: (itemId: Id, sessionId: Id) => { + if (locks.get(itemId) === sessionId) { + return locks.delete(itemId) + } + + return false + }, + delete: (itemId: string) => locks.delete(itemId), + entries: () => locks.entries(), + } +} + +export function obtainLock(locks: ServerSideBoardState["locks"], e: BoardItemEvent, socket: WsWrapper) { + if (isPersistableBoardItemEvent(e)) { + const itemIds = getItemIds(e) + // Since we are operating on multiple items at a time, locking must succeed for all of them + // for the action to succeed + return itemIds.every((id) => locks.lockItem(id, socket.id)) + } else { + const { itemId, action } = e + switch (action) { + case "item.lock": + return locks.lockItem(itemId, socket.id) + case "item.unlock": + return locks.unlockItem(itemId, socket.id) + } + } +} + +export function releaseLocksFor(socket: WsWrapper) { + getActiveBoards().forEach((state) => { + const locks = state.locks + for (const [itemId, sessionId] of locks.entries()) { + if (socket.id === sessionId) { + locks.delete(itemId) + } + } + }) +} diff --git a/backend/src/oauth.ts b/backend/src/oauth.ts new file mode 100644 index 0000000..2f0ad44 --- /dev/null +++ b/backend/src/oauth.ts @@ -0,0 +1,60 @@ +import Cookies from "cookies" +import { Express, Request, Response } from "express" +import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" +import { removeAuthenticatedUser, setAuthenticatedUser } from "./http-session" +import { GoogleAuthProvider, googleConfig } from "./google-auth" +import { GenericOIDCAuthProvider, genericOIDCConfig } from "./generic-oidc-auth" + +export interface AuthProvider { + getAuthPageURL: () => Promise + getAccountFromCode: (code: string) => Promise + logout?: (req: Request, res: Response) => Promise +} + +export function setupAuth(app: Express, provider: AuthProvider) { + app.get("/login", async (req, res) => { + new Cookies(req, res).set("returnTo", parseReturnPath(req), { + maxAge: 24 * 3600 * 1000, + httpOnly: true, + }) // Max 24 hours + const authUrl = await provider.getAuthPageURL() + res.setHeader("content-type", "text/html") + res.send(`Signing in...`) + }) + + app.get("/logout", async (req, res) => { + removeAuthenticatedUser(req, res) + if (provider.logout) { + await provider.logout(req, res) + } else { + res.redirect(parseReturnPath(req)) + } + }) + + app.get("/google-callback", async (req, res) => { + const code = (req.query?.code as string) || "" + const cookies = new Cookies(req, res) + const returnTo = cookies.get("returnTo") || "/" + cookies.set("returnTo", "", { maxAge: 0, httpOnly: true }) + console.log("Verifying google auth", code) + try { + const userInfo = await provider.getAccountFromCode(code) + console.log("Found", userInfo) + setAuthenticatedUser(req, res, userInfo) + res.redirect(returnTo) + } catch (e) { + console.error(e) + res.status(500).send("Internal error") + } + }) + + function parseReturnPath(req: Request) { + return (req.query.returnTo as string) || "/" + } +} + +export const authProvider: AuthProvider | null = googleConfig + ? GoogleAuthProvider(googleConfig) + : genericOIDCConfig + ? GenericOIDCAuthProvider(genericOIDCConfig) + : null diff --git a/backend/src/professions.ts b/backend/src/professions.ts new file mode 100644 index 0000000..0dae1b3 --- /dev/null +++ b/backend/src/professions.ts @@ -0,0 +1,72 @@ +export const professions = [ + "Accountant", + "Actress", + "Architect", + "Astronomer", + "Author", + "Baker", + "Bricklayer", + "Bus driver", + "Butcher", + "Carpenter", + "Chef", + "Cleaner", + "Coder", + "Consultant", + "Dentist", + "Designer", + "DevOps specialist", + "Doctor", + "Dustman", + "Electrician", + "Engineer", + "Factory worker", + "Farmer", + "Fire fighter", + "Fisherman", + "Florist", + "Gardener", + "Hairdresser", + "Journalist", + "Judge", + "Lawyer", + "Lecturer", + "Librarian", + "Lifeguard", + "Mechanic", + "Model", + "Newsreader", + "Nurse", + "Optician", + "Painter", + "Pharmacist", + "Photographer", + "Physician", + "Pilot", + "Plumber", + "Pointy-haired boss", + "Politician", + "Postman", + "Product Owner", + "Real estate agent", + "Receptionist", + "Scientist", + "Scrum master", + "Secretary", + "Shop assistant", + "Tailor", + "Taxi driver", + "Teacher", + "Tester", + "Translator", + "Traffic warden", + "Travel agent", + "Vet", + "UNIX guru", + "Waiter/Waitress", + "Window cleaner", +] + +export function randomProfession() { + return professions[Math.floor(Math.random() * professions.length)] +} diff --git a/backend/src/require-auth.ts b/backend/src/require-auth.ts new file mode 100644 index 0000000..03fe955 --- /dev/null +++ b/backend/src/require-auth.ts @@ -0,0 +1,17 @@ +import { Express, Request, Response, NextFunction } from "express" +import { getAuthenticatedUser } from "./http-session" + +export const REQUIRE_AUTH = process.env.REQUIRE_AUTH === "true" + +export function possiblyRequireAuth(app: Express) { + if (REQUIRE_AUTH) { + // Require authentication for all resources except the URLs bound by setupAuth above + app.use("/", (req: Request, res: Response, next: NextFunction) => { + if (!getAuthenticatedUser(req)) { + res.redirect("/login") + } else { + next() + } + }) + } +} diff --git a/backend/src/s3.ts b/backend/src/s3.ts new file mode 100644 index 0000000..23ecf11 --- /dev/null +++ b/backend/src/s3.ts @@ -0,0 +1,26 @@ +import * as AWS from "aws-sdk" + +const s3Config = { + region: "eu-north-1", + apiVersion: "2006-03-01", + signatureVersion: "v4", +} + +let s3Instance: AWS.S3 | null = null + +export const s3 = () => { + s3Instance = s3Instance || new AWS.S3(s3Config) + return s3Instance +} + +export function getSignedPutUrl(Key: string) { + const signedUrlExpireSeconds = 60 * 5 + + const url = s3().getSignedUrl("putObject", { + Bucket: "r-board-assets", + Key, + Expires: signedUrlExpireSeconds, + }) + + return url +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..17c91a3 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,60 @@ +import dotenv from "dotenv" +dotenv.config() + +import * as Http from "http" +import { exampleBoard } from "../../common/src/domain" +import { awaitSavingChanges } from "./board-state" +import { createBoard, fetchBoard } from "./board-store" +import { initDB } from "./db" +import { startExpressServer } from "./express-server" +import { terminateSessions } from "./websocket-sessions" + +let stopServer: (() => void) | null = null + +async function shutdown() { + console.log("Shutdown initiated. Closing sockets.") + if (stopServer) stopServer() + terminateSessions() + console.log("Shutdown in progress. Waiting for all changes to be saved...") + await awaitSavingChanges() + console.log("Shutdown complete. Exiting process.") + process.exit(0) +} + +process.on("SIGTERM", () => { + console.log("Received SIGTERM. Initiating shutdown.") + shutdown() +}) + +const PORT = parseInt(process.env.PORT || "1337") +const HTTPS_PORT = process.env.HTTPS_PORT ? parseInt(process.env.HTTPS_PORT) : undefined +const BIND_UWEBSOCKETS_TO_PORT = process.env.BIND_UWEBSOCKETS_TO_PORT === "true" +if (BIND_UWEBSOCKETS_TO_PORT && process.env.UWEBSOCKETS_PORT) { + throw Error("Cannot have both UWEBSOCKETS_PORT and BIND_UWEBSOCKETS_TO_PORT envs") +} +const HTTP_PORT = BIND_UWEBSOCKETS_TO_PORT ? null : PORT +const UWEBSOCKETS_PORT = BIND_UWEBSOCKETS_TO_PORT + ? PORT + : process.env.UWEBSOCKETS_PORT + ? parseInt(process.env.UWEBSOCKETS_PORT) + : null + +initDB() + .then(async () => { + if (!(await fetchBoard("default"))) { + await createBoard(exampleBoard) + } + }) + .then(() => { + if (HTTP_PORT) { + stopServer = startExpressServer(HTTP_PORT, HTTPS_PORT) + } + if (UWEBSOCKETS_PORT) { + import("./uwebsockets-server").then((uwebsockets) => { + uwebsockets.startUWebSocketsServer(UWEBSOCKETS_PORT) + }) + } + }) + .catch((e) => { + console.error(e) + }) diff --git a/backend/src/storage.ts b/backend/src/storage.ts new file mode 100644 index 0000000..963ac7a --- /dev/null +++ b/backend/src/storage.ts @@ -0,0 +1,9 @@ +import { getSignedPutUrl as s3GetSignedPutUrl } from "./s3" +import { StorageBackend } from "./config" + +function localFSGetSignedPutUrl(Key: string): string { + return "/assets/" + Key +} + +export const createGetSignedPutUrl = (storageBackend: StorageBackend): ((key: string) => string) => + storageBackend.type === "AWS" ? s3GetSignedPutUrl : localFSGetSignedPutUrl diff --git a/backend/src/tools/wait-for-db.ts b/backend/src/tools/wait-for-db.ts new file mode 100644 index 0000000..59ddade --- /dev/null +++ b/backend/src/tools/wait-for-db.ts @@ -0,0 +1,11 @@ +import TcpPortUsed from "tcp-port-used" +const port = 13338 +;(async function () { + console.log(`Waiting for DB to bind port ${port}...`) + try { + await TcpPortUsed.waitUntilUsed(port, 100, 10000) + } catch { + console.error("Timed out waiting for DB") + process.exit(1) + } +})() diff --git a/backend/src/user-store.ts b/backend/src/user-store.ts new file mode 100644 index 0000000..a6903b1 --- /dev/null +++ b/backend/src/user-store.ts @@ -0,0 +1,62 @@ +import { inTransaction, withDBClient } from "./db" +import * as uuid from "uuid" +import { EventUserInfo, Id, RecentBoard, EventUserInfoAuthenticated, ISOTimeStamp } from "../../common/src/domain" +import { uniqBy } from "lodash" + +export function getUserIdForEmail(email: string): Promise { + return inTransaction(async (client) => { + let id: string | undefined = (await client.query("SELECT id FROM app_user WHERE email=$1", [email])).rows[0]?.id + if (!id) { + id = uuid.v4() + await client.query("INSERT INTO app_user (id, email) VALUES ($1, $2);", [id, email]) + } + return id + }) +} + +export async function associateUserWithBoard( + userId: string, + boardId: Id, + lastOpened: ISOTimeStamp = new Date().toISOString(), +) { + try { + await inTransaction(async (client) => { + await client.query( + `INSERT INTO user_board (user_id, board_id, last_opened) values ($1, $2, $3) + ON CONFLICT (user_id, board_id) DO UPDATE SET last_opened=EXCLUDED.last_opened`, + [userId, boardId, lastOpened], + ) + }) + } catch (e) { + console.error(`Failed to associate user ${userId} with board ${boardId}`) + } +} + +export async function dissociateUserWithBoard(userId: string, boardId: Id) { + try { + await inTransaction(async (client) => { + await client.query(`DELETE FROM user_board WHERE user_id=$1 and board_id=$2`, [userId, boardId]) + }) + } catch (e) { + console.error(`Failed to dissociate user ${userId} with board ${boardId}`) + } +} + +export async function getUserAssociatedBoards(user: EventUserInfoAuthenticated): Promise { + const rows = ( + await withDBClient((client) => + client.query( + "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", + [user.userId], + ), + ) + ).rows + return rows.map((r) => { + return { + id: r.id, + name: r.name, + userEmail: user.email, + opened: r.last_opened.toISOString(), + } + }) +} diff --git a/backend/src/uwebsockets-server.ts b/backend/src/uwebsockets-server.ts new file mode 100644 index 0000000..9f01674 --- /dev/null +++ b/backend/src/uwebsockets-server.ts @@ -0,0 +1,105 @@ +import uws from "uWebSockets.js" +import { EventFromServer } from "../../common/src/domain" +import * as uuid from "uuid" +import { connectionHandler, MessageHandler } from "./connection-handler" +import { handleBoardEvent } from "./board-event-handler" +import { createGetSignedPutUrl } from "./storage" +import { getConfig } from "./config" +import * as L from "lonna" +import { handleCommonEvent } from "./common-event-handler" + +export const WsWrapper = (ws: uws.WebSocket) => { + const errorE = L.bus() + const closeE = L.bus() + const msgE = L.bus() + + const onError = (f: () => void) => { + errorE.forEach(f) + } + const onMessage = (f: (msg: object) => void) => { + msgE.forEach(f) + } + const onClose = (f: () => void) => { + closeE.forEach(f) + } + return { + send: (buffer: Buffer) => { + try { + ws.send(buffer, false) + } catch (e) { + ws.close() + } + }, + onError, + onMessage, + onClose, + id: uuid.v4(), + close: () => { + ws.close() + closeE.push() + }, + errorE, + closeE, + msgE, + } +} +type WsWrapper = ReturnType + +type WsUserData = { + handler: MessageHandler +} + +export function startUWebSocketsServer(port: number) { + const config = getConfig() + const app = uws.App() + + const sockets = new Map() + const textDecoder = new TextDecoder() + + const signedPutUrl = createGetSignedPutUrl(config.storageBackend) + mountWs("/socket/lobby", () => handleBoardEvent(null, signedPutUrl)) + mountWs("/socket/board/:boardId", (req) => handleBoardEvent(req.getParameter(0), signedPutUrl)) + + app.get("/", (res) => res.writeStatus("200 OK").end("Sorry, we only serve websocket clients here.")) + + function mountWs(path: string, f: (req: uws.HttpRequest) => MessageHandler) { + app.ws(path, { + upgrade: (res, req, context) => { + res.upgrade( + { + handler: f(req), + } as WsUserData, + req.getHeader("sec-websocket-key"), + req.getHeader("sec-websocket-protocol"), + req.getHeader("sec-websocket-extensions"), + // 3 headers are used to setup websocket + context, + ) + }, + open: (ws) => { + const wrapper = WsWrapper(ws) + const handler = (ws as WsUserData & uws.WebSocket).handler + sockets.set(ws, wrapper) + connectionHandler(wrapper, handler) + }, + message: (ws, message, isBinary) => { + if (isBinary) throw Error("Binary message") + const object = JSON.parse(textDecoder.decode(message)) + const wrapper = sockets.get(ws) + if (!wrapper) { + throw Error("Wrapper not found for socket " + ws) + } + wrapper.msgE.push(object) + }, + close: (ws) => { + const wrapper = sockets.get(ws) + if (wrapper) { + wrapper.closeE.push() + } + }, + }) + } + app.listen(port, () => { + console.log("uWebSockets listening on " + port) + }) +} diff --git a/backend/src/websocket-sessions.ts b/backend/src/websocket-sessions.ts new file mode 100644 index 0000000..60ae8dd --- /dev/null +++ b/backend/src/websocket-sessions.ts @@ -0,0 +1,347 @@ +import { OAuthAuthenticatedUser } from "../../common/src/authenticated-user" +import { + AccessLevel, + AckJoinBoard, + AuthLogout, + BoardHistoryEntry, + CURSOR_POSITIONS_ACTION_TYPE, + EventFromServer, + EventUserInfoAuthenticated, + Id, + ItemLocks, + JoinedBoard, + Serial, + SessionUserInfo, + SetNickname, + UnidentifiedUserInfo, + UserCursorPosition, + UserInfoUpdate, + getBoardAttributes, + isBoardHistoryEntry, +} from "../../common/src/domain" +import { ServerSideBoardState, maybeGetBoard } from "./board-state" +import { getBoardHistory } from "./board-store" +import { closeYjsSocketsBySessionId } from "./board-yjs-server" +import { randomProfession } from "./professions" +import { getUserIdForEmail } from "./user-store" +import { WsWrapper, toBuffer } from "./ws-wrapper" + +export type UserSession = { + readonly sessionId: Id + boardSession: UserSessionBoardEntry | null + userInfo: SessionUserInfo + sendEvent: (event: EventFromServer) => void + isOnBoard: (boardId: Id) => boolean + close(): void +} + +export type UserSessionBoardEntry = { + boardId: Id + status: "ready" | "buffering" + accessLevel: AccessLevel + bufferedEvents: BoardHistoryEntry[] +} + +/* +socket: WsWrapper + boards: Id[] + userInfo: EventUserInfo + */ +export type SocketId = string + +const sessions: Record = {} + +const everyoneOnTheBoard = (boardId: string) => { + const boardState = maybeGetBoard(boardId) + if (!boardState) { + console.warn(`Trying to send to a board not in memory: ${boardId}`) + return [] + } + return boardState.sessions +} +const sendTo = (recipients: UserSession[], message: EventFromServer) => { + recipients.forEach((c) => c.sendEvent(message)) +} +const everyoneElseOnTheSameBoard = (boardId: Id, session?: UserSession) => + everyoneOnTheBoard(boardId).filter((s) => s !== session) + +export function startSession(socket: WsWrapper) { + sessions[socket.id] = userSession(socket) +} + +function userSession(socket: WsWrapper): UserSession { + const sessionId = socket.id + + function sendEvent(event: EventFromServer) { + if (isBoardHistoryEntry(event)) { + const entry = session.boardSession + if (!entry) throw Error("Board " + event.boardId + " not found for session " + sessionId) + if (entry.status === "buffering") { + entry.bufferedEvents.push(event) + return + } + } + socket.send(toBuffer(event)) + } + const session: UserSession = { + sessionId, + userInfo: anonymousUser("Anonymous " + randomProfession()), + boardSession: null, + sendEvent, + isOnBoard: (boardId: Id) => session.boardSession != null && session.boardSession.boardId === boardId, + close: () => socket.close(), + } + sessions[socket.id] = session + return session +} + +function anonymousUser(nickname: string): UnidentifiedUserInfo { + return { userType: "unidentified", nickname } +} + +export function endSession(socket: WsWrapper) { + const sessionId = socket.id + const session = sessions[sessionId] + if (!session) { + console.warn(`Ending non-existing session ${sessionId}`) + return + } + if (session.boardSession) { + const boardState = maybeGetBoard(session.boardSession.boardId) + if (boardState) { + boardState.sessions = boardState.sessions.filter((s) => s.sessionId !== sessionId) + broadcastBoardEvent({ action: "board.left", boardId: boardState.board.id, sessionId }) + } else { + console.warn(`Board state not found when ending session: ${session.boardSession.boardId}`) + } + } + delete sessions[socket.id] + closeYjsSocketsBySessionId(sessionId) +} +export function getBoardSessionCount(id: Id) { + return everyoneOnTheBoard(id).length +} +export function getSession(socket: WsWrapper): UserSession | undefined { + return getSessionById(socket.id) +} + +export function getSessionById(sessionId: string): UserSession | undefined { + return sessions[sessionId] +} + +export function terminateSessions() { + Object.values(sessions).forEach((session) => session.close()) +} + +export async function addSessionToBoard( + boardState: ServerSideBoardState, + origin: WsWrapper, + accessLevel: AccessLevel, + initAtSerial?: Serial, +): Promise { + const session = sessions[origin.id] + if (!session) throw new Error("No session found for socket " + origin.id) + const boardId = boardState.board.id + if (!boardState.sessions.includes(session)) { + boardState.sessions = [...boardState.sessions, session] + } + const initDiff = initAtSerial && boardState.board.serial - initAtSerial + if (initDiff && initDiff > Object.keys(boardState.board.items).length) { + console.log(`Sending fresh board state for board ${boardId} instead of diff (${initDiff} events to sync)`) + initAsNew(session, boardId, accessLevel, boardState) + } else if (initAtSerial) { + const entry: UserSessionBoardEntry = { boardId, status: "buffering", accessLevel, bufferedEvents: [] } + // 1. Add session to the board with "buffering" status, to collect all events that were meant to be sent during this async initialization + session.boardSession = entry + try { + const boardAttributes = getBoardAttributes(boardState.board, session.userInfo) + + //console.log(`Starting session at ${initAtSerial}`) + // 2. capture all board events that haven't yet been flushed to the DB + const inMemoryEvents = (boardState.currentlyStoring?.events ?? []) + .concat(boardState.recentEvents) + .filter((e) => e.serial! > initAtSerial) + + // 3. Fetch events from DB as chunks + // IMPORTANT NOTE: this is the only await here and must remain so, as the logic here depends on everything else being synchronous. + console.log(`Loading board history for board ${boardState.board.id} session at serial ${initAtSerial}`) + let first = true + await getBoardHistory(boardState.board.id, initAtSerial, (chunk) => { + // Send a chunk of events with done: false, so that client knows to wait for more + session.sendEvent({ + action: "board.init.diff", + first, + last: false, + boardAttributes, + recentEvents: chunk, + initAtSerial, + accessLevel, + }) + first = false + }) + + console.log(`Got board history for board ${boardState.board.id} session at serial ${initAtSerial}`) + + // 4. Send the last chunk containing both the inMemoryEvents and the buffered events (done: true) + // In memory events: not yet flushed to DB when query was made + // Buffered events: events that occurred after the in memory events were captured + session.sendEvent({ + action: "board.init.diff", + boardAttributes, + first, + last: true, + recentEvents: [...inMemoryEvents, ...entry.bufferedEvents], + initAtSerial, + accessLevel, + }) + + // 5. Set the client to "ready" status so that new events will be flushed + entry.status = "ready" + entry.bufferedEvents = [] + } catch (e) { + console.warn( + `Failed to bootstrap client on board ${boardId} at serial ${initAtSerial}. Sending full state.`, + ) + entry.status = "ready" + entry.bufferedEvents = [] + session.sendEvent({ + action: "board.init", + board: boardState.board, + accessLevel, + }) + } + } else { + initAsNew(session, boardId, accessLevel, boardState) + } + + // TODO SECURITY: don't reveal authenticated emails to unidentified users on same board + // TODO: what to include in joined events? Not just nickname, as we want to show who's identified (beside the cursor) + + session.sendEvent({ + action: "board.join.ack", + boardId: boardState.board.id, + sessionId: session.sessionId, + nickname: session.userInfo.nickname, + } as AckJoinBoard) + + // Notify new user of existing users + everyoneOnTheBoard(boardState.board.id).forEach((s) => { + session.sendEvent({ + action: "board.joined", + boardId: boardState.board.id, + sessionId: s.sessionId, + ...s.userInfo, + } as JoinedBoard) + }) + + // Notify existing users of new user + broadcastJoinEvent(boardState.board.id, session) +} + +function initAsNew(session: UserSession, boardId: string, accessLevel: AccessLevel, boardState: ServerSideBoardState) { + session.boardSession = { boardId, status: "ready", accessLevel, bufferedEvents: [] } + session.sendEvent({ + action: "board.init", + board: boardState.board, + accessLevel, + }) +} + +export function setNicknameForSession(event: SetNickname, origin: WsWrapper) { + const session = getSession(origin) + if (!session) { + console.warn(`Session not found: ${origin.id}`) + return + } + + session.userInfo = + session.userInfo.userType === "unidentified" + ? anonymousUser(event.nickname) + : { ...session.userInfo, nickname: event.nickname } + const updateInfo: UserInfoUpdate = { + action: "userinfo.set", + sessionId: session.sessionId, + ...session.userInfo, + } + if (session.boardSession) { + sendTo(everyoneOnTheBoard(session.boardSession.boardId), updateInfo) + } +} + +export async function setVerifiedUserForSession( + event: OAuthAuthenticatedUser, + session: UserSession, +): Promise { + const userId = await getUserIdForEmail(event.email) + session.userInfo = { + userType: "authenticated", + nickname: event.name, + name: event.name, + email: event.email, + picture: event.picture, + domain: event.domain, + userId, + } + if (session.boardSession) { + // TODO SECURITY: don't reveal authenticated emails to unidentified users on same board + sendTo(everyoneElseOnTheSameBoard(session.boardSession.boardId, session), { + action: "user.login", + email: event.email, + name: event.name, + picture: event.picture, + }) + } + return session.userInfo +} + +export function logoutUser(event: AuthLogout, origin: WsWrapper) { + const session = getSession(origin) + if (!session) { + console.warn("Session not found for socket " + origin.id) + } else { + session.userInfo = { userType: "unidentified", nickname: session.userInfo.nickname } + } +} + +export function broadcastBoardEvent(event: EventFromServer & { boardId: string }, origin?: UserSession) { + //console.log("Broadcast", event.action, "to", everyoneElseOnTheSameBoard(event.boardId, origin).length) + sendTo(everyoneElseOnTheSameBoard(event.boardId, origin), event) +} + +export function broadcastJoinEvent(boardId: Id, session: UserSession) { + sendTo(everyoneElseOnTheSameBoard(boardId, session), { + action: "board.joined", + boardId, + sessionId: session.sessionId, + ...session.userInfo, + } as JoinedBoard) +} + +export function broadcastCursorPositions(boardId: Id, positions: Record) { + sendTo(everyoneOnTheBoard(boardId), { action: CURSOR_POSITIONS_ACTION_TYPE, p: positions }) +} + +const BROADCAST_DEBOUNCE_MS = 20 + +// Debounce by 20ms per board id, otherwise every item interaction (e.g. drag on 10 items, one event each) broadcasts locks +export const broadcastItemLocks = (() => { + let timeouts: Record = {} + const hasActiveTimer = (boardId: string) => timeouts[boardId] !== undefined + + return function _broadcastItemLocks(boardId: string, locks: ItemLocks) { + if (hasActiveTimer(boardId)) { + return + } + timeouts[boardId] = setTimeout(() => { + const boardState = maybeGetBoard(boardId) + if (boardState) { + sendTo(boardState.sessions, { action: "board.locks", boardId, locks }) + } + timeouts[boardId] = undefined + }, BROADCAST_DEBOUNCE_MS) + } +})() + +export function getSessionCount() { + return Object.values(sessions).length +} diff --git a/backend/src/ws-wrapper.ts b/backend/src/ws-wrapper.ts new file mode 100644 index 0000000..b541ac2 --- /dev/null +++ b/backend/src/ws-wrapper.ts @@ -0,0 +1,48 @@ +import * as WebSocket from "ws" +import * as uuid from "uuid" +import { EventFromServer } from "../../common/src/domain" + +export const WsWrapper = (ws: WebSocket) => { + const onError = (f: () => void) => { + ws.addEventListener("error", f) + } + const onMessage = (f: (msg: object) => void) => { + ws.addEventListener("message", (msg: any) => { + try { + f(JSON.parse(msg.data)) + } catch (e) { + console.error("Error in WsWrapper/onMessage. Closing connection.", e) + ws.close() + } + }) + } + const onClose = (f: () => void) => { + ws.addEventListener("close", f) + } + return { + send: (buffer: Buffer) => { + try { + ws.send(buffer, { binary: false }) + } catch (e) { + ws.close() + } + }, + onError, + onMessage, + onClose, + id: uuid.v4(), + close: () => ws.close(), + } +} +export type WsWrapper = ReturnType + +type CachedBuffer = { msg: EventFromServer; buffer: Buffer } +let cachedBuffer: CachedBuffer | null = null +export function toBuffer(msg: EventFromServer) { + // We cache the latest buffer to avoid creating a new buffer for every message. + if (cachedBuffer && cachedBuffer.msg === msg) { + return cachedBuffer.buffer + } + cachedBuffer = { msg, buffer: Buffer.from(JSON.stringify(msg)) } + return cachedBuffer.buffer +} diff --git a/backend/src/y-websocket-server/Docs.ts b/backend/src/y-websocket-server/Docs.ts new file mode 100644 index 0000000..f1d9c50 --- /dev/null +++ b/backend/src/y-websocket-server/Docs.ts @@ -0,0 +1,58 @@ +import { WSSharedDoc } from "./WSSharedDoc" +import { Persistence } from "./Persistence" + +export interface DocsOptions { + persistence?: Persistence + gc?: boolean +} + +interface DocState { + doc: WSSharedDoc + fetchPromise: Promise +} + +export class Docs { + readonly docs = new Map() + readonly persistence: Persistence | null + readonly gc: boolean + + constructor(options: DocsOptions = {}) { + this.persistence = options.persistence || null + this.gc = options.gc ?? true + } + + /** + * Gets a Y.Doc by name, whether in memory or on disk + */ + getYDoc(docname: string): WSSharedDoc { + return this.getDocState(docname).doc + } + + async getYDocAndWaitForFetch(docname: string): Promise { + const state = this.getDocState(docname) + await state.fetchPromise + return state.doc + } + + private getDocState(docname: string): DocState { + let state = this.docs.get(docname) + if (!state) { + const doc = new WSSharedDoc(this, docname) + console.log(`Loading document ${doc.name} into memory`) + doc.gc = this.gc + if (this.persistence !== null) { + void this.persistence.bindState(docname, doc) + } + const fetchPromise = + this.persistence !== null ? this.persistence.bindState(docname, doc) : Promise.resolve() + state = { doc, fetchPromise } + this.docs.set(docname, state) + } + return state + } + + deleteYDoc(doc: WSSharedDoc) { + console.log(`Purging document ${doc.name} from memory`) + this.docs.delete(doc.name) + } +} diff --git a/backend/src/y-websocket-server/Persistence.ts b/backend/src/y-websocket-server/Persistence.ts new file mode 100644 index 0000000..585c1e2 --- /dev/null +++ b/backend/src/y-websocket-server/Persistence.ts @@ -0,0 +1,25 @@ +import * as Y from "yjs" + +export interface Persistence { + bindState: (docName: string, ydoc: Y.Doc) => Promise + writeState: (docName: string, ydoc: Y.Doc) => Promise +} + +export function createLevelDbPersistence(persistenceDir: string): Persistence { + console.info('Persisting documents to "' + persistenceDir + '"') + // @ts-ignore + const LeveldbPersistence = require("y-leveldb").LeveldbPersistence + const ldb = new LeveldbPersistence(persistenceDir) + return { + bindState: async (docName, ydoc) => { + const persistedYdoc = await ldb.getYDoc(docName) + const newUpdates = Y.encodeStateAsUpdate(ydoc) + ldb.storeUpdate(docName, newUpdates) + Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)) + ydoc.on("update", (update) => { + ldb.storeUpdate(docName, update) + }) + }, + writeState: async (docName, ydoc) => {}, + } +} diff --git a/backend/src/y-websocket-server/Protocol.ts b/backend/src/y-websocket-server/Protocol.ts new file mode 100644 index 0000000..d9939de --- /dev/null +++ b/backend/src/y-websocket-server/Protocol.ts @@ -0,0 +1,2 @@ +export const messageSync = 0 +export const messageAwareness = 1 diff --git a/backend/src/y-websocket-server/WSSharedDoc.ts b/backend/src/y-websocket-server/WSSharedDoc.ts new file mode 100644 index 0000000..2a3992a --- /dev/null +++ b/backend/src/y-websocket-server/WSSharedDoc.ts @@ -0,0 +1,105 @@ +import * as Y from "yjs" + +import * as awarenessProtocol from "y-protocols/awareness" +import * as syncProtocol from "y-protocols/sync" + +import * as encoding from "lib0/encoding" +import * as WebSocket from "ws" +import { Docs } from "./Docs" +import { messageAwareness, messageSync } from "./Protocol" + +export const wsReadyStateConnecting = 0 +export const wsReadyStateOpen = 1 +export const wsReadyStateClosing = 2 +export const wsReadyStateClosed = 3 + +export class WSSharedDoc extends Y.Doc { + private docs: Docs + readonly name: string + private conns: Map> = new Map>() + readonly awareness = new awarenessProtocol.Awareness(this) + + constructor(docs: Docs, name: string) { + super({ gc: docs.gc }) + this.docs = docs + this.name = name + this.awareness.setLocalState(null) + + const awarenessChangeHandler = ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + conn: WebSocket, + ) => { + const changedClients = added.concat(updated, removed) + if (conn !== null) { + const connControlledIDs = this.conns.get(conn) + if (connControlledIDs !== undefined) { + added.forEach((clientID) => { + connControlledIDs.add(clientID) + }) + removed.forEach((clientID) => { + connControlledIDs.delete(clientID) + }) + } + } + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageAwareness) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients), + ) + const buff = encoding.toUint8Array(encoder) + this.conns.forEach((_, c) => { + this.send(c, buff) + }) + } + this.awareness.on("update", awarenessChangeHandler) + + const updateHandler = (update: Uint8Array, origin: any, doc: WSSharedDoc) => { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeUpdate(encoder, update) + const message = encoding.toUint8Array(encoder) + doc.conns.forEach((_, conn) => this.send(conn, message)) + } + + this.on("update", updateHandler) + } + + send(conn: WebSocket, m: Uint8Array) { + if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) { + this.closeConn(conn) + } + try { + conn.send(m, (err: any) => { + err != null && this.closeConn(conn) + }) + } catch (e) { + console.error("Failed to send message to client. Closing connection.", e) + this.closeConn(conn) + } + } + + closeConn(conn: WebSocket) { + if (this.conns.has(conn)) { + const controlledIds = this.conns.get(conn)! + this.conns.delete(conn) + awarenessProtocol.removeAwarenessStates(this.awareness, Array.from(controlledIds), null) + if (this.conns.size === 0 && this.docs.persistence !== null) { + // if persisted, we store state and destroy ydocument + this.docs.persistence.writeState(this.name, this).then(() => { + this.destroy() + }) + this.docs.deleteYDoc(this) + } + } + conn.close() + } + + addConnection(conn: WebSocket) { + this.conns.set(conn, new Set()) + } + + hasConnection(conn: WebSocket) { + return this.conns.has(conn) + } +} diff --git a/backend/src/y-websocket-server/YWebSocketServer.ts b/backend/src/y-websocket-server/YWebSocketServer.ts new file mode 100644 index 0000000..c3832a8 --- /dev/null +++ b/backend/src/y-websocket-server/YWebSocketServer.ts @@ -0,0 +1,131 @@ +import * as awarenessProtocol from "y-protocols/awareness" +import * as syncProtocol from "y-protocols/sync" + +import * as decoding from "lib0/decoding" +import * as encoding from "lib0/encoding" +import * as WebSocket from "ws" +import { Docs, DocsOptions } from "./Docs" +import { messageAwareness, messageSync } from "./Protocol" +import { WSSharedDoc } from "./WSSharedDoc" + +const pingTimeout = 30000 + +export default class YWebSocketServer { + docs: Docs + constructor(options?: DocsOptions) { + this.docs = new Docs(options) + } + + async setupWSConnection(conn: WebSocket, docName: string, readOnly: boolean) { + conn.binaryType = "arraybuffer" + // get doc, initialize if it does not exist yet + const doc = this.docs.getYDoc(docName) + + console.log(`YJS connection established for ${docName}`) + + doc.addConnection(conn) + // listen and reply to events + conn.on("message", (message: ArrayBuffer) => messageListener(conn, doc, readOnly, new Uint8Array(message))) + + // Check if connection is still alive + let pongReceived = true + const pingInterval = setInterval(() => { + if (!pongReceived) { + if (doc.hasConnection(conn)) { + doc.closeConn(conn) + } + clearInterval(pingInterval) + } else if (doc.hasConnection(conn)) { + pongReceived = false + try { + conn.ping() + } catch (e) { + doc.closeConn(conn) + clearInterval(pingInterval) + } + } + }, pingTimeout) + conn.on("close", () => { + doc.closeConn(conn) + clearInterval(pingInterval) + }) + conn.on("pong", () => { + pongReceived = true + }) + // put the following in a variables in a block so the interval handlers don't keep in in + // scope + { + // send sync step 1 + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageSync) + syncProtocol.writeSyncStep1(encoder, doc) + doc.send(conn, encoding.toUint8Array(encoder)) + const awarenessStates = doc.awareness.getStates() + if (awarenessStates.size > 0) { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, messageAwareness) + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())), + ) + doc.send(conn, encoding.toUint8Array(encoder)) + } + } + } +} + +// Read-only implementation found at https://discuss.yjs.dev/t/read-only-or-one-way-only-sync/135/3 +const readSyncMessage = ( + decoder: decoding.Decoder, + encoder: encoding.Encoder, + doc: WSSharedDoc, + readOnly = false, + transactionOrigin: any, +) => { + const messageType = decoding.readVarUint(decoder) + switch (messageType) { + case syncProtocol.messageYjsSyncStep1: + syncProtocol.readSyncStep1(decoder, encoder, doc) + break + case syncProtocol.messageYjsSyncStep2: + if (!readOnly) syncProtocol.readSyncStep2(decoder, doc, transactionOrigin) + break + case syncProtocol.messageYjsUpdate: + if (!readOnly) syncProtocol.readUpdate(decoder, doc, transactionOrigin) + break + default: + throw new Error("Unknown message type") + } + return messageType +} + +const messageListener = (conn: WebSocket, doc: WSSharedDoc, readOnly: boolean, message: Uint8Array) => { + try { + const encoder = encoding.createEncoder() + const decoder = decoding.createDecoder(message) + const messageType = decoding.readVarUint(decoder) + switch (messageType) { + case messageSync: + encoding.writeVarUint(encoder, messageSync) + readSyncMessage(decoder, encoder, doc, readOnly, conn) + + // If the `encoder` only contains the type of reply message and no + // message, there is no need to send the message. When `encoder` only + // contains the type of reply, its length is 1. + if (encoding.length(encoder) > 1) { + doc.send(conn, encoding.toUint8Array(encoder)) + } + break + case messageAwareness: { + awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn) + break + } + default: { + console.warn("Unexpected message type" + messageType) + } + } + } catch (err) { + console.error(err) + doc.emit("error", [err]) + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..92a19e2 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist", + "rootDir": "..", + "sourceMap": true + } +} diff --git a/benchmark/benchmark.ts b/benchmark/benchmark.ts new file mode 100644 index 0000000..d0f57fa --- /dev/null +++ b/benchmark/benchmark.ts @@ -0,0 +1,127 @@ +import { uniqueId } from "lodash" +import { arrayToRecordById } from "../common/src/arrays" +import { boardReducer } from "../common/src/board-reducer" +import { Board, Item, Note, newNote } from "../common/src/domain" + +type Foo = Board + +function createRandomItems(count: number): Item[] { + const items: Item[] = [] + for (let i = 0; i < count; i++) { + items.push({ + id: uniqueId(), + type: "note", + color: "yellow", + height: 100, + width: 100, + x: Math.random() * 10000, + y: Math.random() * 10000, + z: 0, + shape: "square", + text: "Hello world", + }) + } + return items +} + +function createTestBoard(size = 1000): Board { + return { + id: uniqueId(), + height: 10000, + width: 10000, + serial: 0, + name: "Bigass board", + items: arrayToRecordById(createRandomItems(size)), + connections: [], + } +} + +let testBoards = Array.from({ length: 100 }, () => { + const board = createTestBoard(100) + const items = Object.values(board.items) as Note[] + return { + board, + items, + } +}) + +console.time("10000 item.updates on a randomly picked 100 item board") +for (let i = 0; i < 10000; i++) { + let randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)] + // const itemsArray = Object.values(randomBoard.items) as Note[] + const item = randomBoard.items[i % randomBoard.items.length] + ;[randomBoard.board] = boardReducer(randomBoard.board, { + boardId: randomBoard.board.id, + action: "item.update", + items: [ + { + ...item, + text: "Hello world", + }, + ], + }) +} +console.timeEnd("10000 item.updates on a randomly picked 100 item board") + +testBoards = Array.from({ length: 100 }, () => { + const board = createTestBoard(100) + const items = Object.values(board.items) as Note[] + return { + board, + items, + } +}) + +console.time("10000 item.adds on a randomly picked 100 item board") +for (let i = 0; i < 10000; i++) { + const randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)] + const item = newNote("foo") + ;[randomBoard.board] = boardReducer(randomBoard.board, { + boardId: randomBoard.board.id, + action: "item.add", + items: [item], + connections: [], + }) +} +console.timeEnd("10000 item.adds on a randomly picked 100 item board") + +testBoards = Array.from({ length: 100 }, () => { + const board = createTestBoard(100) + const items = Object.values(board.items) as Note[] + return { + board, + items, + } +}) + +console.time("10000 item.moves on a randomly picked 100 item board") +for (let i = 0; i < 10000; i++) { + const randomBoard = testBoards[Math.floor(Math.random() * testBoards.length)] + const item = randomBoard.items[i % randomBoard.items.length] + ;[randomBoard.board] = boardReducer(randomBoard.board, { + boardId: randomBoard.board.id, + action: "item.move", + items: [ + { + ...item, + x: Math.random() * 10000, + y: Math.random() * 10000, + }, + ], + connections: [], + }) +} +console.timeEnd("10000 item.moves on a randomly picked 100 item board") + +console.time("Reference: creating 10000 input objects and doing nothing") +const noop = (item: any) => { + item.text +} +for (let i = 0; i < 10000; i++) { + const item = newNote("foo") + noop({ + ...item, + text: "Hello world", + }) +} +console.timeEnd("Reference: creating 10000 input objects and doing nothing") diff --git a/common/src/action-folding.ts b/common/src/action-folding.ts new file mode 100644 index 0000000..d5a3b8d --- /dev/null +++ b/common/src/action-folding.ts @@ -0,0 +1,87 @@ +import { arrayEquals, arrayIdAndKeysMatch, arrayIdMatch, idsOf } from "./arrays" +import { + AppEvent, + BoardHistoryEntry, + CURSOR_POSITIONS_ACTION_TYPE, + MoveItem, + isBoardHistoryEntry, + isSameUser, +} from "./domain" + +type FoldOptions = { + cursorsOnly?: boolean +} + +const defaultOptions = { + foldAddUpdate: true, + cursorsOnly: false, +} + +export const CURSORS_ONLY: FoldOptions = { cursorsOnly: true } + +export function foldActions(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null { + if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) { + if (options.cursorsOnly) return null + if (!isSameUser(a.user, b.user)) return null + const folded = foldActions_(a, b, options) + if (!folded) return null + const firstSerial = a.firstSerial ? a.firstSerial : a.serial + const serial = b.serial + return { ...(folded as BoardHistoryEntry), serial, firstSerial } as BoardHistoryEntry + } else { + return foldActions_(a, b, options) + } +} +/* +Folding can be done if in any given state S, applying actions A and B consecutively can be replaced with a single action C. +This function should return that composite action or null if folding is not possible. +*/ +export function foldActions_(a: AppEvent, b: AppEvent, options: FoldOptions = defaultOptions): AppEvent | null { + if (a.action === CURSOR_POSITIONS_ACTION_TYPE && b.action === CURSOR_POSITIONS_ACTION_TYPE) { + return b + } + if (a.action === "cursor.move" && b.action === "cursor.move" && b.boardId === a.boardId) { + return b // This is a local cursor move + } + if (options.cursorsOnly) return null + + if (isBoardHistoryEntry(a) && isBoardHistoryEntry(b)) { + if (!isSameUser(a.user, b.user)) return null + } + if (a.action === "item.front") { + if (b.action === "item.front" && b.boardId === a.boardId && arrayEquals(b.itemIds, a.itemIds)) return b + } else if (a.action === "item.move") { + if (b.action === "item.move" && b.boardId === a.boardId && everyMovedItemMatches(b, a)) return b + } else if (a.action === "item.update") { + if ( + b.action === "item.update" && + b.boardId === a.boardId && + arrayIdAndKeysMatch(b.items, a.items) && + arrayIdAndKeysMatch(b.connections ?? [], a.connections ?? []) + ) { + return b + } + } else if (a.action === "item.lock" || a.action === "item.unlock") { + if (b.action === a.action && b.boardId === a.boardId && b.itemId === a.itemId) return b + } else if (a.action === "connection.modify" && b.action === "connection.modify") { + if (arrayIdMatch(a.connections, b.connections)) return b + } else if (a.action === "connection.modify" && b.action === "connection.delete") { + if (arrayEquals(b.connectionIds, idsOf(a.connections))) return b + } + return null +} + +function everyMovedItemMatches(evt: MoveItem, evt2: MoveItem) { + return arrayIdMatch(evt.items, evt2.items) && arrayIdMatch(evt.connections, evt2.connections) +} + +export function addOrReplaceEvent(event: E, q: E[], options: FoldOptions = defaultOptions): E[] { + for (let i = 0; i < q.length; i++) { + let eventInQueue = q[i] + const folded = foldActions(eventInQueue, event, options) + if (folded) { + return [...q.slice(0, i), folded, ...q.slice(i + 1)] as E[] + } + } + return q.concat(event) +} diff --git a/common/src/arrays.ts b/common/src/arrays.ts new file mode 100644 index 0000000..317ad6a --- /dev/null +++ b/common/src/arrays.ts @@ -0,0 +1,37 @@ +import { isArray, isEqual } from "lodash" + +export function toArray(x: T | T[]) { + if (isArray(x)) return x + return [x] +} + +export function arrayIdMatch(a: T[] | T, b: T[] | T) { + return arrayEquals(idsOf(a), idsOf(b)) +} + +export function arrayObjectKeysMatch(a: T[] | T, b: T[] | T) { + return arrayEquals(keysOf(a), keysOf(b)) +} + +export function arrayIdAndKeysMatch(a: T[] | T, b: T[] | T) { + return arrayIdMatch(a, b) && arrayObjectKeysMatch(a, b) +} + +export function idsOf(a: T[] | T): string[] { + return toArray(a).map((x) => x.id) +} + +export function keysOf(a: T[] | T): string[][] { + return toArray(a).map((x) => Object.keys(x)) +} + +export function arrayEquals(a: T[] | T, b: T[] | T) { + return isEqual(toArray(a), toArray(b)) +} + +export function arrayToRecordById(arr: T[], init: Record = {}): Record { + return arr.reduce((acc: Record, elem: T) => { + acc[elem.id] = elem + return acc + }, init) +} diff --git a/common/src/assertNotNull.ts b/common/src/assertNotNull.ts new file mode 100644 index 0000000..53f5898 --- /dev/null +++ b/common/src/assertNotNull.ts @@ -0,0 +1,4 @@ +export function assertNotNull(x: T | null | undefined): T { + if (x === null || x === undefined) throw Error("Assertion failed: " + x) + return x +} diff --git a/common/src/authenticated-user.ts b/common/src/authenticated-user.ts new file mode 100644 index 0000000..5795fa8 --- /dev/null +++ b/common/src/authenticated-user.ts @@ -0,0 +1,6 @@ +export type OAuthAuthenticatedUser = { + name: string + email: string + picture?: string + domain: string | null +} diff --git a/common/src/board-crdt-helper.ts b/common/src/board-crdt-helper.ts new file mode 100644 index 0000000..b75ac5a --- /dev/null +++ b/common/src/board-crdt-helper.ts @@ -0,0 +1,40 @@ +import * as Y from "yjs" +import { Board, Id, Item, QuillDelta, isTextItem } from "./domain" + +export function getCRDTField(doc: Y.Doc, itemId: Id, fieldName: string) { + return doc.getText(`items.${itemId}.${fieldName}`) +} + +export function augmentBoardWithCRDT(doc: Y.Doc, board: Board): Board { + const items = augmentItemsWithCRDT(doc, Object.values(board.items)) + return { + ...board, + items: Object.fromEntries(items.map((i) => [i.id, i])), + } +} + +export function augmentItemsWithCRDT(doc: Y.Doc, items: Item[]): Item[] { + return items.map((item) => { + if (isTextItem(item) && item.crdt) { + const field = getCRDTField(doc, item.id, "text") + const textAsDelta = field.toDelta() as QuillDelta + const text = field.toString() + return { ...item, textAsDelta, text } + } + return item + }) +} + +export function importItemsIntoCRDT(doc: Y.Doc, items: Item[], options?: { fallbackToText: boolean }) { + for (const item of items) { + if (isTextItem(item) && item.crdt) { + if (item.textAsDelta) { + getCRDTField(doc, item.id, "text").applyDelta(item.textAsDelta) + } else if (options?.fallbackToText) { + getCRDTField(doc, item.id, "text").insert(0, item.text) + } else { + throw Error("textAsDelta is missing ") + } + } + } +} diff --git a/common/src/board-reducer.benchmark.ts b/common/src/board-reducer.benchmark.ts new file mode 100644 index 0000000..ccde8c1 --- /dev/null +++ b/common/src/board-reducer.benchmark.ts @@ -0,0 +1,59 @@ +import _ from "lodash" +import { NOTE_COLORS } from "./colors" +import { Board } from "./domain" +import * as uuid from "uuid" +import { boardReducer } from "./board-reducer" +import { assertNotNull } from "./assertNotNull" + +function createBoard(): Board { + const itemCount = 10000 + const board: Board = { + id: "0f5b9d6c-02c2-4b81-beb7-3a3b9035e8a2", + name: "Perf3", + width: 800, + height: 600, + serial: 320577, + connections: [], + items: {}, + } + for (let i = 0; i < itemCount; i++) { + const id = uuid.v4() + board.items[id] = { + id, + type: "text", + x: Math.random() * 800, + y: Math.random() * 600, + z: 3, + width: 5, + height: 5, + text: "Hello world", + fontSize: 12, + locked: false, + color: "#FBFC86", + } + } + return board +} + +const board = createBoard() +const boardId = board.id +const eventCount = 1000 +const items = Object.values(board.items) + +const started = new Date().getTime() +for (let i = 0; i < eventCount; i++) { + const target = assertNotNull(_.sample(items)) + const updated = { ...target, text: "EDIT " + i, color: _.sample(NOTE_COLORS)?.color! } + + boardReducer( + board, + { + action: "item.update", + boardId, + items: [updated], + }, + { inplace: true }, + ) +} +const elapsed = new Date().getTime() - started +console.log(`Processed ${eventCount} events in ${elapsed}ms. (${eventCount / elapsed} events/ms)`) diff --git a/common/src/board-reducer.ts b/common/src/board-reducer.ts new file mode 100644 index 0000000..4e68d0f --- /dev/null +++ b/common/src/board-reducer.ts @@ -0,0 +1,708 @@ +import { partition } from "lodash" +import { maybeChangeContainerForItem } from "../../frontend/src/board/item-setcontainer" +import { arrayToRecordById } from "./arrays" +import { rerouteConnection, resolveEndpoint } from "./connection-utils" +import { + Board, + Connection, + ConnectionEndPoint, + ConnectionUpdate, + Container, + findItem, + findItemIdsRecursively, + getConnection, + getEndPointItemId, + getItem, + Id, + Image, + isBoardHistoryEntry, + isContainedBy, + isContainer, + isItem, + isItemEndPoint, + isTextItem, + Item, + ItemUpdate, + MoveItem, + Note, + PersistableBoardItemEvent, + Point, + Text, + TextItem, + Update, + Video, +} from "./domain" +import { equalRect, Rect } from "./geometry" +import { + BoardPermission, + canChangeFont, + canChangeShapeAndColor, + canChangeText, + canChangeTextAlign, + canDelete, + canMove, + nullablePermission, +} from "../../frontend/src/board/board-permissions" + +type BoardReducerOptions = { + inplace?: boolean + strictOnSerials?: boolean +} + +export function boardReducer( + board: Board, + event: PersistableBoardItemEvent, + options: BoardReducerOptions = {}, +): [Board, (() => PersistableBoardItemEvent) | null] { + const inplace = options.inplace ?? false + if (isBoardHistoryEntry(event) && event.serial) { + const firstSerial = event.firstSerial ? event.firstSerial : event.serial + if (firstSerial !== board.serial + 1) { + const message = `Serial skip on ${event.action}, ${board.serial} -> ${firstSerial} (firstSerial ${event.firstSerial} serial ${event.serial})` + if (options.strictOnSerials) { + throw Error(message) + } else { + console.warn(message) + } + } + board = { ...board, serial: event.serial } + } + //console.log(event.action, inplace) + switch (event.action) { + case "connection.add": { + const newConnections = event.connections + + for (let connection of newConnections) { + validateConnection(board, connection) + + if (board.connections.some((c) => c.id === connection.id)) { + throw Error(`Connection ${connection.id} already exists on board ${board.id}`) + } + } + + return [ + { + ...board, + connections: applyListModification( + board.connections, + (cs) => { + cs.push(...newConnections) + }, + inplace, + ), + }, + () => ({ + action: "connection.delete", + boardId: event.boardId, + connectionIds: newConnections.map((c) => c.id), + }), + ] + } + case "connection.modify": { + const connections = event.connections.filter(canMove) + + const existingConnections = connections.map((r) => { + validateConnection(board, r) + const existingConnection = board.connections.find((c) => c.id === r.id) + if (!existingConnection) { + throw Error(`Trying to modify nonexisting connection ${r.id} on board ${board.id}`) + } + return existingConnection + }) + + return [ + { + ...board, + connections: applyListModification( + board.connections, + (cs) => replaceById(cs, connections), + inplace, + ), + }, + () => ({ action: "connection.modify", boardId: event.boardId, connections: existingConnections }), + ] + } + case "connection.delete": { + const ids = new Set(event.connectionIds) + + const existingConnections = board.connections.filter((c) => ids.has(c.id) || !canDelete(c)) + + return [ + { ...board, connections: board.connections.filter((c) => !ids.has(c.id)) }, + () => ({ action: "connection.add", boardId: event.boardId, connections: existingConnections }), + ] + } + case "board.rename": + return [{ ...board, name: event.name }, null] + case "board.setAccessPolicy": + return [{ ...board, accessPolicy: event.accessPolicy }, null] + case "item.bootstrap": + //if (board.items.length > 0) throw Error("Trying to bootstrap non-empty board") + return [{ ...board, items: event.items, connections: event.connections }, null] + case "item.add": + if (event.items.some((a) => board.items[a.id])) { + throw new Error("Adding duplicate item " + JSON.stringify(event.items)) + } + + const updatedItems = applyModification( + board.items, + (items) => { + event.items.forEach((item) => { + if ( + item.containerId && + !findItem(board)(item.containerId) && + !findItem(arrayToRecordById(event.items))(item.containerId) + ) { + // Add item but don't try to assign to a non-existing container + items[item.id] = { ...item, containerId: undefined } + } else { + items[item.id] = item + } + }, {}) + }, + inplace, + ) + + const boardWithAddedItems = { ...board, items: updatedItems } + + const connectionsToAdd = event.connections || [] + + connectionsToAdd.forEach((connection) => { + validateConnection(boardWithAddedItems, connection) + + if (board.connections.some((c) => c.id === connection.id)) { + throw Error(`Connection ${connection.id} already exists on board ${board.id}`) + } + }) + + return [ + { + ...boardWithAddedItems, + connections: applyListModification( + board.connections, + (cs) => { + cs.push(...connectionsToAdd) + }, + inplace, + ), + }, + () => ({ + action: "item.delete", + boardId: board.id, + itemIds: event.items.map((i) => i.id), + connectionIds: event.connections.map((c) => c.id), + }), + ] + case "item.font.increase": + return [ + { + ...board, + items: applyFontSize( + board.items, + 1.1, + filterItemIdsByPermissions(event.itemIds, board, canChangeFont), + inplace, + ), + }, + () => ({ + ...event, + action: "item.font.decrease", + }), + ] + case "item.font.decrease": + return [ + { + ...board, + items: applyFontSize( + board.items, + 1 / 1.1, + filterItemIdsByPermissions(event.itemIds, board, canChangeFont), + inplace, + ), + }, + () => ({ + ...event, + action: "item.font.increase", + }), + ] + case "item.update": { + const updatedConnections = updateConnections(board, event.connections || []) + const updatedItems = updateItems(board, event.items, updatedConnections, inplace) + return [ + { + ...board, + items: updatedItems, + connections: updatedConnections, + }, + () => ({ + action: "item.update", + boardId: board.id, + items: event.items.map((update) => copyMatchingKeysFromOriginal(update, getItem(board)(update.id))), + connections: (event.connections || []).map((update) => + copyMatchingKeysFromOriginal(update, getConnection(board)(update.id)), + ), + }), + ] + } + case "item.move": + return [ + moveItems(board, event, inplace), + () => ({ + action: "item.move", + boardId: board.id, + items: event.items.map((i) => { + const item = getItem(board)(i.id) + return { id: i.id, x: item.x, y: item.y, containerId: item.containerId } + }), + connections: event.connections.map((c) => { + const conn = getConnection(board)(c.id) + const startPoint = resolveEndpoint(conn.from, board) + return { id: c.id, x: startPoint.x, y: startPoint.y } + }), + }), + ] + case "item.delete": { + const itemIds = filterItemIdsByPermissions(event.itemIds, board, canDelete) + const connectionIds = filterConnectionIdsByPermissions(event.connectionIds, board, canDelete) + const itemIdsToDelete = findItemIdsRecursively(itemIds, board) + const connectionIdsToDelete = new Set(connectionIds) + const updatedItems = inplace ? board.items : { ...board.items } + itemIdsToDelete.forEach((id) => { + delete updatedItems[id] + }) + + const [connectionsToKeep, connectionsDeleted] = partition( + board.connections, + (c) => + !connectionIdsToDelete.has(c.id) && + !(c.containerId && itemIdsToDelete.has(c.containerId)) && + (!isItemEndPoint(c.from) || !itemIdsToDelete.has(getEndPointItemId(c.from))) && + (!isItemEndPoint(c.to) || !itemIdsToDelete.has(getEndPointItemId(c.to))), + ) + + return [ + { + ...board, + connections: connectionsToKeep, + items: updatedItems, + }, + () => ({ + action: "item.add", + boardId: board.id, + items: Array.from(itemIdsToDelete).map(getItem(board)), + connections: connectionsDeleted, + }), + ] + } + case "item.front": + let maxZ = 0 + let maxZCount = 0 + const itemsList = Object.values(board.items) + for (let i of itemsList) { + if (i.z > maxZ) { + maxZCount = 1 + maxZ = i.z + } else if (i.z === maxZ) { + maxZCount++ + } + } + const isFine = (item: Item) => { + return !event.itemIds.includes(item.id) || item.z === maxZ + } + if (maxZCount === event.itemIds.length && itemsList.every(isFine)) { + // Requested items already on front + return [board, null] + } + + const updated = event.itemIds.reduce( + (acc: Record, id) => { + const item = board.items[id] + if (!item) { + console.warn(`Warning: trying to "item.front" nonexisting item ${id} on board ${board.id}`) + return acc + } + const u = item.type !== "container" ? { ...item, z: maxZ + 1 } : item + acc[u.id] = u + return acc + }, + inplace ? board.items : {}, + ) + + return [ + { + ...board, + items: inplace + ? board.items + : { + ...board.items, + ...updated, + }, + }, + null, + ] // TODO: return item.back + default: + console.warn("Unknown event", event) + return [board, null] + } +} + +function copyMatchingKeysFromOriginal(update: Update, original: T): Update { + const keysAndValues = Object.keys(update).map((key) => [key, original[key as keyof T]]) + const result = Object.fromEntries(keysAndValues) + return result +} + +function validateConnection(board: Board, connection: Connection) { + validateEndPoint(board, connection, "from") + validateEndPoint(board, connection, "to") +} + +function validateEndPoint(board: Board, connection: Connection, key: "to" | "from") { + const endPoint = connection[key] + if (isItemEndPoint(endPoint)) { + const toItem = board.items[getEndPointItemId(endPoint)] + if (!toItem) { + throw Error(`Connection ${connection.id} refers to nonexisting item ${endPoint}`) + } + } +} + +function updateConnections(board: Board, updates: ConnectionUpdate[]): Connection[] { + if (updates.length === 0) return board.connections + updates = filterConnectionUpdatesByPermissions(updates, board) + const updatedConnections = updates.map((update) => { + const existing = board.connections.find((c) => c.id === update.id) + if (!existing) { + throw Error(`Trying to modify nonexisting connection ${update.id} on board ${board.id}`) + } + const updated = { ...existing, ...update } + validateConnection(board, updated) + return updated + }) + return board.connections.map((c) => { + const replacement = updatedConnections.find((r) => r.id === c.id) + return replacement ? replacement : c + }) +} + +function updateItems( + board: Board, + updateList: ItemUpdate[], + updatedConnections: Connection[], + inplace: boolean, +): Record { + updateList = filterItemUpdatesByPermissions(updateList, board) + const updatedItems: Item[] = updateList.map((update) => ({ ...board.items[update.id], ...update } as Item)) + + const resultItems = applyModification( + board.items, + (items) => { + arrayToRecordById(updatedItems, items) + }, + inplace, + ) + + updatedItems.filter(isContainer).forEach((container) => { + const previous = board.items[container.id] + if (previous && !equalRect(previous, container)) { + // Container shape changed -> check items + Object.values(board.items) + .filter( + (i) => + i.containerId === container.id || // Check all previously contained items + containedBy(i, container), // Check all items inside the new bounds + ) + .forEach((item) => { + const newContainer = maybeChangeContainerForItem(item, resultItems) + if (newContainer?.id !== item.containerId) { + resultItems[item.id] = { ...item, containerId: newContainer ? newContainer.id : undefined } + } + }) + } + }) + + function setVisibilityRecursively(parent: Item, hidden: boolean) { + const children = Object.values(resultItems).filter((i) => i.containerId === parent.id) + children.forEach((child) => { + const resultItem = { ...child, hidden } + if (!hidden && isContainer(resultItem) && resultItem.contentsHidden) { + resultItem.contentsHidden = false + } + resultItems[child.id] = resultItem + setVisibilityRecursively(child, hidden) + }) + } + + function adjustConnectionVisibility() { + updatedConnections.forEach((c, i) => { + let shouldHide: boolean + if (c.containerId) { + // When a connection has containerId, it should be hidden if the container is hidden (or has contentsHidden) + // A connection practically has containerId in case its endpoints are not attached to an item and it's contained by a container + const container = resultItems[c.containerId] + shouldHide = (container?.hidden || (isContainer(container) && container.contentsHidden)) ?? false + } else { + // In other cases, hide connections in case either end is connected to a hidden item + const from = resolveEndpoint(c.from, resultItems) + const to = resolveEndpoint(c.to, resultItems) + shouldHide = ((isItem(from) && from.hidden) || (isItem(to) && to.hidden)) ?? false + } + if (shouldHide !== c.hidden ?? false) { + updatedConnections[i] = { ...c, hidden: shouldHide } + } + }) + } + + updateList.forEach((update) => { + if ("contentsHidden" in update) { + const container = board.items[update.id] + if (container) { + setVisibilityRecursively(container, update.contentsHidden ?? false) + adjustConnectionVisibility() + } + } + }) + + return resultItems +} + +function applyModification( + items: Record, + modification: (items: Record) => void, + inplace: boolean, +): Record { + const updated = inplace ? items : { ...items } + modification(updated) + return updated +} + +function applyListModification(list: T[], modification: (list: T[]) => void, inplace: boolean) { + const newList = inplace ? list : [...list] + modification(newList) + return newList +} + +function replaceById(list: T[], replacements: T[]) { + replacements.forEach((replacement) => { + const index = list.findIndex((item) => item.id === replacement.id) + if (index === -1) { + throw Error(`Trying to replace nonexisting item ${replacement.id}`) + } + list[index] = replacement + }) +} + +function applyFontSize(items: Record, factor: number, itemIds: Id[], inplace: boolean) { + return applyModification( + items, + (items) => { + itemIds.forEach((id) => { + const u = items[id] && isTextItem(items[id]) ? (items[id] as TextItem) : null + if (u) { + items[u.id] = { + ...u, + fontSize: ((u as TextItem).fontSize || 1) * factor, + } + } + }) + }, + inplace, + ) +} + +function filterItemIdsByPermissions(itemIds: Id[], board: Board, permission: BoardPermission) { + return itemIds.filter((id) => nullablePermission(permission)(findItem(board)(id))) +} + +function filterConnectionIdsByPermissions(connectionIds: Id[], board: Board, permission: BoardPermission) { + return connectionIds.filter((id) => permission(getConnection(board)(id))) +} + +function filterMoveByPermissions(event: MoveItem, board: Board) { + return { + ...event, + items: event.items.filter((i) => canMove(getItem(board)(i.id))), + connections: event.connections.filter((c) => canMove(getConnection(board)(c.id))), + } +} + +function filterItemUpdatesByPermissions(updates: ItemUpdate[], board: Board): ItemUpdate[] { + type AnyItemKey = keyof Note | keyof Text | keyof Container | keyof Image | keyof Video + const propertyToPermissionMapping: Partial> = { + align: canChangeTextAlign, + color: canChangeShapeAndColor, + fontSize: canChangeFont, + x: canMove, + y: canMove, + width: canMove, + height: canMove, + text: canChangeText, + } + return updates.filter((update) => { + const item = findItem(board)(update.id) + if (!item) return false + const keys = Object.keys(update) as AnyItemKey[] + const permissionFns = keys.map((key) => propertyToPermissionMapping[key]) + for (let fn of permissionFns) { + if (fn && !fn(item)) { + console.log("Deny update", keys) + return false + } + } + return true + }) +} + +function filterConnectionUpdatesByPermissions(updates: ConnectionUpdate[], board: Board): ConnectionUpdate[] { + const propertyToPermissionMapping: Partial> = { + from: canMove, + to: canMove, + fromStyle: canChangeShapeAndColor, + toStyle: canChangeShapeAndColor, + controlPoints: canMove, + } + return updates.filter((update) => { + const connection = getConnection(board)(update.id) + const keys = Object.keys(update) as (keyof Connection)[] + const permissionFns = keys.map((key) => propertyToPermissionMapping[key]) + for (let fn of permissionFns) { + if (fn && !fn(connection)) { + console.log("Deny update", keys) + return false + } + } + return true + }) +} + +function moveItems(board: Board, event: MoveItem, inplace: boolean) { + event = filterMoveByPermissions(event, board) + const itemMoves: Record = {} + const itemsOnBoard = board.items + const connectionMovesInEvent = event.connections || [] + + for (let mainItemMove of event.items) { + const { id, x, y, containerId } = mainItemMove + const mainItem = itemsOnBoard[id] + if (mainItem === undefined) { + console.warn("Moving unknown item", id) + continue + } + const xDiff = x - mainItem.x + const yDiff = y - mainItem.y + + for (let movedItem of Object.values(itemsOnBoard)) { + const movedId = movedItem.id + if (movedId === id || isContainedBy(itemsOnBoard, mainItem)(movedItem)) { + const move = { xDiff, yDiff, containerChanged: movedId === id, containerId } + itemMoves[movedId] = move + } + } + } + + const connectionMoves: Record = {} + for (let connection of board.connections) { + const move = findConnectionMove(connection, itemMoves, itemsOnBoard) + if (move) { + connectionMoves[connection.id] = move + } else { + const m = connectionMovesInEvent.find((m) => m.id === connection.id) + if (m) { + const startPoint = resolveEndpoint(connection.from, board) + connectionMoves[connection.id] = { + ends: "both", + xDiff: m.x - startPoint.x, + yDiff: m.y - startPoint.y, + } + } + } + } + + let updatedConnections: Connection[] = board.connections.flatMap((connection) => { + const move = connectionMoves[connection.id] + if (!move) return connection + if (move.ends === "both") { + return { + ...connection, + from: moveEndPoint(connection.from, move), + to: moveEndPoint(connection.to, move), + controlPoints: connection.controlPoints.map((cp) => moveEndPoint(cp, move)), + } as Connection + } + return rerouteConnection(connection, board) + }) + + const updatedItems = Object.entries(itemMoves).reduce( + (items, [id, move]) => { + const item = items[id] + const updated = { ...item, x: item.x + move.xDiff, y: item.y + move.yDiff } + if (move.containerChanged) { + updated.containerId = move.containerId + if (move.containerId) { + const newContainer = board.items[move.containerId] + if ( + newContainer && + (newContainer.hidden || (isContainer(newContainer) && newContainer.contentsHidden)) + ) { + updated.hidden = true + } + } + } + items[id] = updated + return items + }, + inplace ? board.items : { ...board.items }, + ) + + return { + ...board, + items: updatedItems, + connections: updatedConnections, + } +} + +type Move = { xDiff: number; yDiff: number } +type ItemMove = Move & { containerChanged: boolean; containerId: Id | undefined } +type ConnectionMove = (Move & { ends: "both" }) | { ends: "one" } + +function findConnectionMove( + connection: Connection, + moves: Record, + items: Record, +): ConnectionMove | null { + const endPoints = [connection.to, connection.from] + let move: Move | null = null + let partial = false + let hasItemEndPoints = false + for (let endPoint of endPoints) { + if (isItemEndPoint(endPoint)) { + hasItemEndPoints = true + const itemId = getEndPointItemId(endPoint) + if (moves[itemId]) { + move = moves[itemId] + } else { + // linked to item not being moved -> maybe a partial move + partial = true + } + } + } + if (!move && !hasItemEndPoints && connection.containerId) { + move = moves[connection.containerId] + } + if (!move) return null + if (partial) return { ends: "one" } + return { ends: "both", ...move } +} + +function moveEndPoint(endPoint: ConnectionEndPoint, move: Move) { + if (isItemEndPoint(endPoint)) { + return endPoint // points to an item + } + const x = endPoint.x + move.xDiff + const y = endPoint.y + move.yDiff + return { ...endPoint, x, y } +} + +function containedBy(a: Point, b: Rect) { + return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height +} diff --git a/common/src/colors.ts b/common/src/colors.ts new file mode 100644 index 0000000..bafee12 --- /dev/null +++ b/common/src/colors.ts @@ -0,0 +1,26 @@ +export const LIGHT_BLUE = "#9FECFC" +export const LIGHT_GREEN = "#C8FC87" +export const YELLOW = "#FBFC86" +export const ORANGE = "#FDDF90" +export const PINK = "#FDC4E7" +export const LIGHT_PURPLE = "#E0BDFA" +export const RED = "#F62A5C" +export const BLACK = "#000000" +export const LIGHT_GRAY = "#f4f4f6" +export const WHITE = "#ffffff" +export const TRANSPARENT = "#ffffff00" +export const DEFAULT_NOTE_COLOR = YELLOW + +export const NOTE_COLORS = [ + { name: "light-blue", color: LIGHT_BLUE }, + { name: "light-green", color: LIGHT_GREEN }, + { name: "yellow", color: YELLOW }, + { name: "orange", color: ORANGE }, + { name: "pink", color: PINK }, + { name: "light-purple", color: LIGHT_PURPLE }, + { name: "red", color: RED }, + { name: "black", color: BLACK }, + { name: "light-gray", color: LIGHT_GRAY }, + { name: "white", color: WHITE }, + { name: "transparent", color: TRANSPARENT }, +] diff --git a/common/src/connection-utils.ts b/common/src/connection-utils.ts new file mode 100644 index 0000000..8b80151 --- /dev/null +++ b/common/src/connection-utils.ts @@ -0,0 +1,193 @@ +import * as _ from "lodash" +import { maybeChangeContainerForConnection } from "../../frontend/src/board/item-setcontainer" +import { + AttachmentLocation, + AttachmentSide, + Board, + Connection, + ConnectionEndPoint, + ConnectionEndPointToItem, + getEndPointItemId, + getItem, + isItem, + isItemEndPoint, + Item, + ItemAttachmentLocation, + Point, +} from "./domain" +import { centerPoint, containedBy, Rect, subtract } from "./geometry" +import { getAngleDeg, Vector2 } from "./vector2" + +export function resolveEndpoint(e: Point | Item | ConnectionEndPoint, b: Board | Record): Point | Item { + if (isItemEndPoint(e)) { + return resolveItemEndpoint(e, b) + } + return e +} + +export function resolveItemEndpoint(e: ConnectionEndPointToItem, b: Board | Record): Item { + return getItem(b)(getEndPointItemId(e)) +} + +export function findNearestAttachmentLocationForConnectionNode( + i: Point | Item, + reference: Point | Item, +): AttachmentLocation { + if (!isItem(i)) return { side: "none", point: i } + const options: ItemAttachmentLocation[] = findItemAttachmentLocations(i) + const from = centerPoint(reference) + return withStraightestAngle(options, from)! +} + +function angleDiff(option: ItemAttachmentLocation, from: Point) { + const directionFromEndPoint: Vector2 = subtract(from, option.point) + const endpointDirection = getEndPointDirection(option.side) + const diff = Math.abs(getAngleDeg(directionFromEndPoint) - getAngleDeg(endpointDirection)) + if (diff > 180) return 360 - diff + return diff +} + +function withStraightestAngle(options: ItemAttachmentLocation[], to: Point) { + return _.minBy(options, (p) => angleDiff(p, to)) +} + +function getEndPointDirection(side: AttachmentSide): Vector2 { + switch (side) { + case "top": + return Vector2(0, -1) + case "right": + return Vector2(1, 0) + case "bottom": + return Vector2(0, 1) + case "left": + return Vector2(-1, 0) + } +} + +const sides: AttachmentSide[] = ["top", "left", "bottom", "right"] + +function findItemAttachmentLocations(i: Item): ItemAttachmentLocation[] { + return sides.map((side) => findAttachmentLocation(i, side)) +} + +function p(x: number, y: number) { + return { x, y } +} + +export function findAttachmentLocation(i: Item, side: AttachmentSide): ItemAttachmentLocation { + const margin = 0.1 + switch (side) { + case "top": + return { item: i, side, point: p(i.x + i.width / 2, i.y - margin) } + case "left": + return { item: i, side, point: p(i.x - margin, i.y + i.height / 2) } + case "right": + return { item: i, side, point: p(i.x + i.width + margin, i.y + i.height / 2) } + case "bottom": + return { item: i, side, point: p(i.x + i.width / 2, i.y + i.height + margin) } + } +} + +function findMidpoint(fromCoords: AttachmentLocation, toCoords: AttachmentLocation) { + const midpoint = { + x: mid(fromCoords.point.x, toCoords.point.x), + y: mid(fromCoords.point.y, toCoords.point.y), + } + if (toCoords.side === "left" || toCoords.side === "right") { + return { + x: midpoint.x, + y: mid(midpoint.y, toCoords.point.y), + } + } + if (toCoords.side === "top" || toCoords.side === "bottom") { + return { + x: mid(midpoint.x, toCoords.point.x), + y: midpoint.y, + } + } + if (fromCoords.side === "left" || fromCoords.side === "right") { + return { + x: midpoint.x, + y: mid(midpoint.y, fromCoords.point.y), + } + } + if (fromCoords.side === "top" || fromCoords.side === "bottom") { + return { + x: mid(midpoint.x, fromCoords.point.x), + y: midpoint.y, + } + } + return midpoint +} + +function attachmentLocation2EndPoint(l: AttachmentLocation): ConnectionEndPoint { + if (l.side === "none") { + return l.point + } + return { side: l.side, id: l.item.id } +} + +export function rerouteConnection(c: Connection, b: Board): Connection { + const resolvedFrom = resolveEndpoint(c.from, b) + const resolvedTo = resolveEndpoint(c.to, b) + + let to = findNearestAttachmentLocationForConnectionNode(resolvedTo, resolvedFrom) + const from = findNearestAttachmentLocationForConnectionNode(resolvedFrom, to.point) + to = findNearestAttachmentLocationForConnectionNode(resolvedTo, from.point) + + const rerouted: Connection = { + ...c, + from: attachmentLocation2EndPoint(from), + to: attachmentLocation2EndPoint(to), + controlPoints: c.controlPoints.length ? [findMidpoint(from, to)] : [], + } + + const container = maybeChangeContainerForConnection(rerouted, b.items) + + return { ...rerouted, containerId: container ? container.id : undefined } +} + +function rerouteEndPoint(e: ConnectionEndPoint, from: ConnectionEndPoint, b: Board) { + return attachmentLocation2EndPoint( + findNearestAttachmentLocationForConnectionNode(resolveEndpoint(e, b), resolveEndpoint(from, b)), + ) +} + +export function rerouteByNewControlPoints(c: Connection, controlPoints: Point[], b: Board): Connection { + const first = controlPoints[0] + + return { + ...c, + from: rerouteEndPoint(c.from, controlPoints[0] || c.to, b), + to: rerouteEndPoint(c.to, controlPoints[controlPoints.length - 1] || c.from, b), + controlPoints, + } +} + +function mid(x: number, y: number) { + return (x + y) * 0.5 +} + +export const connectionRect = (b: Board | Record) => (c: Connection): Rect => { + const start = resolveEndpoint(c.from, b) + const end = resolveEndpoint(c.to, b) + const allPoints = [start, ...c.controlPoints, end] + const minX = _.min(allPoints.map((p) => p.x))! + const maxX = _.max(allPoints.map((p) => p.x))! + const minY = _.min(allPoints.map((p) => p.y))! + const maxY = _.max(allPoints.map((p) => p.y))! + const x = minX + if (isNaN(x)) { + throw Error("Assertion fail") + } + const y = minY + const width = maxX - minX + const height = maxY - minY + return { x, y, width, height } +} + +export function isFullyContainedConnection(connection: Connection, item: Item, context: Record | Board) { + const start = resolveEndpoint(connection.from, context) + const end = resolveEndpoint(connection.to, context) + return !isItem(start) && !isItem(end) && containedBy(start, item) && containedBy(end, item) +} diff --git a/common/src/domain.ts b/common/src/domain.ts new file mode 100644 index 0000000..d7f6668 --- /dev/null +++ b/common/src/domain.ts @@ -0,0 +1,756 @@ +import * as t from "io-ts" +import * as uuid from "uuid" +import { LocalStorageBoard } from "../../frontend/src/store/board-local-store" +import { arrayToRecordById } from "./arrays" +import { DEFAULT_NOTE_COLOR, LIGHT_BLUE, PINK, RED } from "./colors" +import { Rect } from "./geometry" + +export type Id = string +export type ISOTimeStamp = string + +export function newISOTimeStamp(): ISOTimeStamp { + return new Date().toISOString() +} + +export function optional>(c: T) { + return t.union([c, t.undefined, t.null]) +} + +export const CrdtDisabled = undefined +export const CrdtEnabled = 1 as const +export type CrdtMode = typeof CrdtDisabled | typeof CrdtEnabled + +export type BoardAttributes = { + id: Id + name: string + width: number + height: number + accessPolicy?: BoardAccessPolicy + crdt?: CrdtMode +} + +export type BoardContents = { + items: Record + connections: Connection[] +} + +export type Board = BoardAttributes & + BoardContents & { + serial: Serial + } + +export type BoardStub = Pick & { templateId?: Id } + +export const AccessLevelCodec = t.union([ + t.literal("admin"), + t.literal("read-write"), + t.literal("read-only"), + t.literal("none"), +]) +export type AccessLevel = t.TypeOf +export const AccessListEntryCodec = t.union([ + t.type({ + email: t.string, + access: optional(AccessLevelCodec), + }), + t.type({ + domain: t.string, + access: optional(AccessLevelCodec), + }), +]) +export type AccessListEntry = t.TypeOf +export const BoardAccessPolicyDefinedCodec = t.type({ + allowList: t.array(AccessListEntryCodec), + publicRead: optional(t.boolean), + publicWrite: optional(t.boolean), +}) +export type BoardAccessPolicyDefined = t.TypeOf +export const BoardAccessPolicyCodec = t.union([t.undefined, BoardAccessPolicyDefinedCodec]) +export type BoardAccessPolicy = t.TypeOf + +export type EventUserInfo = UnidentifiedUserInfo | SystemUserInfo | EventUserInfoAuthenticated + +export type UnidentifiedUserInfo = { nickname: string; userType: "unidentified" } +export type SystemUserInfo = { nickname: string; userType: "system" } + +export type EventUserInfoAuthenticated = { + nickname: string + userType: "authenticated" + name: string + email: string + userId: string +} + +export type SessionUserInfo = UnidentifiedUserInfo | SystemUserInfo | SessionUserInfoAuthenticated + +export type SessionUserInfoAuthenticated = { + nickname: string + userType: "authenticated" + name: string + email: string + picture: string | undefined + userId: string + domain: string | null +} + +export type UserSessionInfo = SessionUserInfo & { + sessionId: Id +} + +export type BoardHistoryEntry = { + user: EventUserInfo + timestamp: ISOTimeStamp + serial?: Serial + firstSerial?: Serial +} & PersistableBoardItemEvent +export type BoardWithHistory = { board: Board; history: BoardHistoryEntry[] } +export type CompactBoardHistory = { boardAttributes: BoardAttributes; history: BoardHistoryEntry[] } + +export const defaultBoardSize = { width: 800, height: 600 } + +export interface CursorPosition { + x: number + y: number +} + +export type UserCursorPosition = CursorPosition & { + sessionId: Id +} + +export type BoardCursorPositions = Record + +export type Color = string + +export type ItemBounds = { x: number; y: number; width: number; height: number; z: number } +export type LockState = false | "locked" | "read-only" +export type ItemProperties = { id: string; containerId?: string; locked: LockState; hidden?: boolean } & ItemBounds + +export const ITEM_TYPES = { + NOTE: "note", + TEXT: "text", + IMAGE: "image", + VIDEO: "video", + CONTAINER: "container", +} as const +export type ItemType = typeof ITEM_TYPES[keyof typeof ITEM_TYPES] +export type QuillDelta = any // TODO: define this properly +export type TextItemProperties = ItemProperties & { + text: string + fontSize?: number + align?: Align + crdt?: CrdtMode + textAsDelta?: QuillDelta +} +export type NoteShape = "round" | "square" | "rect" | "diamond" +export type Note = TextItemProperties & { + type: typeof ITEM_TYPES.NOTE + color: Color + shape: NoteShape | undefined +} +export type Text = TextItemProperties & { type: typeof ITEM_TYPES.TEXT; color: Color } +export type Image = ItemProperties & { type: typeof ITEM_TYPES.IMAGE; assetId: string; src?: string } +export type Video = ItemProperties & { type: typeof ITEM_TYPES.VIDEO; assetId: string; src?: string } +export type Container = TextItemProperties & { + type: typeof ITEM_TYPES.CONTAINER + color: Color + contentsHidden?: boolean +} + +export type Point = { x: number; y: number } +export function Point(x: number, y: number) { + return { x, y } +} +export const isPoint = (u: unknown): u is Point => typeof u === "object" && !!u && "x" in u && "y" in u +export type ConnectionEndStyle = "none" | "arrow" | "black-dot" +export type Connection = { + id: Id + from: ConnectionEndPoint + controlPoints: Point[] + to: ConnectionEndPoint + containerId?: string + locked: LockState + fromStyle: ConnectionEndStyle + toStyle: ConnectionEndStyle + pointStyle: "none" | "black-dot" + action: "connect" | "line" + hidden?: boolean +} +export type ConnectionEndPoint = Point | ConnectionEndPointToItem +export type ConnectionEndPointToItem = Id | ConectionEndPointDirectedToItem +export type ConectionEndPointDirectedToItem = { id: Id; side: AttachmentSide } +export function getEndPointItemId(e: ConnectionEndPointToItem) { + if (typeof e === "string") return e + return e.id +} +export function isItemEndPoint(e: ConnectionEndPoint): e is ConnectionEndPointToItem { + if (typeof e === "string") return true + if ("side" in e) return true + return false +} +export function isDirectedItemEndPoint(e: ConnectionEndPoint): e is ConectionEndPointDirectedToItem { + return isItemEndPoint(e) && typeof e === "object" +} +export type AttachmentSide = "left" | "right" | "top" | "bottom" +export type AttachmentLocation = { side: "none"; point: Point } | ItemAttachmentLocation +export type ItemAttachmentLocation = { side: AttachmentSide; point: Point; item: Item } + +export type RenderableConnection = Omit & { + from: AttachmentLocation + to: AttachmentLocation +} + +export type TextItem = Note | Text | Container +export type ColoredItem = Item & { color: Color } +export type ShapedItem = Note +export type Item = TextItem | Image | Video +export type ItemLocks = Record + +export type RecentBoardAttributes = { id: Id; name: string } +export type RecentBoard = RecentBoardAttributes & { opened: ISOTimeStamp; userEmail: string | null } + +export type BoardEvent = { boardId: Id } +export type UIEvent = BoardItemEvent | ClientToServerRequest | LocalUIEvent +export type LocalUIEvent = Undo | Redo | SetLocalBoard | GoOnline | BoardLoggedOut | GoOffline | TextFormat +export type EventFromServer = BoardHistoryEntry | BoardStateSyncEvent | LoginResponse | AckAddBoard | ServerConfig +export type ServerConfig = { + action: "server.config" + authSupported: boolean + assetStorageURL: string + crdt: "true" | "false" | "opt-in" | "opt-in-authenticated" +} +export type Serial = number +export type AppEvent = + | BoardItemEvent + | BoardStateSyncEvent + | LocalUIEvent + | ClientToServerRequest + | LoginResponse + | AckAddBoard + | ServerConfig +export type EventWrapper = { + events: AppEvent[] + ackId?: string +} +export type PersistableBoardItemEvent = + | AddItem + | UpdateItem + | MoveItem + | DeleteItem + | AddConnection + | ModifyConnection + | DeleteConnection + | IncreaseItemFont + | DecreaseItemFont + | BringItemToFront + | BootstrapBoard + | RenameBoard + | SetBoardAccessPolicy +export type BoardInit = InitBoardNew | InitBoardDiff +export type TransientBoardItemEvent = LockItem | UnlockItem +export type BoardItemEvent = PersistableBoardItemEvent | TransientBoardItemEvent +export type BoardStateSyncEvent = + | BoardInit + | RecentBoardsFromServer + | GotBoardLocks + | CursorPositions + | JoinedBoard + | LeftBoard + | UserLoggedIn + | AckJoinBoard + | DeniedJoinBoard + | UserInfoUpdate + | ActionApplyFailed + | AssetPutUrlResponse + | Ack + | BringAllToMe + +export type ClientToServerRequest = + | CursorMove + | AddBoard + | LockItem + | UnlockItem + | JoinBoard + | AssociateBoard + | DissociateBoard + | SetNickname + | AssetPutUrlRequest + | AuthJWTLogin + | UserLoggedIn + | AuthLogout + | Ping + | BringAllToMe + +export type LoginResponse = + | { action: "auth.login.response"; success: false } + | { action: "auth.login.response"; success: true; userId: string } +export type AddConnection = { action: "connection.add"; boardId: Id; connections: Connection[] } +export type ModifyConnection = { action: "connection.modify"; boardId: Id; connections: Connection[] } +export type DeleteConnection = { action: "connection.delete"; boardId: Id; connectionIds: Id[] } +export type UserLoggedIn = { + action: "user.login" + name: string + email: string + picture: string | undefined +} +export type AuthJWTLogin = { + action: "auth.login.jwt" + jwt: string +} +export type AuthLogout = { action: "auth.logout" } +export type Ping = { action: "ping" } +export type AddItem = { action: "item.add"; boardId: Id; items: Item[]; connections: Connection[] } +export type UpdateItem = { action: "item.update"; boardId: Id; items: ItemUpdate[]; connections?: ConnectionUpdate[] } +export type Update = Partial & { id: Id } +export type ItemUpdate = Update +export type ConnectionUpdate = Update +export type MoveItem = { + action: "item.move" + boardId: Id + items: { id: Id; x: number; y: number; containerId?: Id | undefined }[] + connections: { id: Id; x: number; y: number }[] // Coordinates are for connection start point. +} +export type IncreaseItemFont = { action: "item.font.increase"; boardId: Id; itemIds: Id[] } +export type DecreaseItemFont = { action: "item.font.decrease"; boardId: Id; itemIds: Id[] } +export type BringItemToFront = { action: "item.front"; boardId: Id; itemIds: Id[] } +export type DeleteItem = { action: "item.delete"; boardId: Id; itemIds: Id[]; connectionIds: Id[] } +export type BootstrapBoard = { action: "item.bootstrap"; boardId: Id } & BoardContents +export type LockItem = { action: "item.lock"; boardId: Id; itemId: Id } +export type UnlockItem = { action: "item.unlock"; boardId: Id; itemId: Id } +export type GotBoardLocks = { action: "board.locks"; boardId: Id; locks: ItemLocks } +export type AddBoard = { action: "board.add"; payload: Board | BoardStub } +export type AckAddBoard = { action: "board.add.ack"; boardId: Id } +export type JoinBoard = { action: "board.join"; boardId: Id; initAtSerial?: Serial } +export type BringAllToMe = { action: "user.bringAllToMe"; boardId: Id; sessionId: Id; viewRect: Rect; nickname: string } +export type AssociateBoard = { action: "board.associate"; boardId: Id; lastOpened: ISOTimeStamp } +export type DissociateBoard = { action: "board.dissociate"; boardId: Id } +export type SetBoardAccessPolicy = { + action: "board.setAccessPolicy" + boardId: Id + accessPolicy: BoardAccessPolicyDefined +} +export type AckJoinBoard = { action: "board.join.ack"; boardId: Id } & UserSessionInfo +export type DeniedJoinBoard = + | { + action: "board.join.denied" + boardId: Id + reason: "unauthorized" | "forbidden" | "not found" + } + | { + action: "board.join.denied" + boardId: Id + reason: "redirect" + wsAddress: string + } +export type RecentBoardsFromServer = { action: "user.boards"; email: string; boards: RecentBoard[] } +export type Ack = { action: "ack"; ackId: string; serials: Record } +export type ActionApplyFailed = { action: "board.action.apply.failed" } +export type JoinedBoard = { action: "board.joined"; boardId: Id } & UserSessionInfo +export type LeftBoard = { action: "board.left"; boardId: Id; sessionId: Id } +export type UserInfoUpdate = { action: "userinfo.set" } & UserSessionInfo +export type InitBoardNew = { action: "board.init"; board: Board; accessLevel: AccessLevel } +export type InitBoardDiff = { + action: "board.init.diff" + initAtSerial: Serial + first: boolean + last: boolean + recentEvents: BoardHistoryEntry[] + boardAttributes: BoardAttributes + accessLevel: AccessLevel +} +export type RenameBoard = { action: "board.rename"; boardId: Id; name: string } +export type CursorMove = { action: "cursor.move"; position: CursorPosition; boardId: Id } +export type SetNickname = { action: "nickname.set"; nickname: string } +export type AssetPutUrlRequest = { action: "asset.put.request"; assetId: string } +export type AssetPutUrlResponse = { action: "asset.put.response"; assetId: string; signedUrl: string } +export type Undo = { action: "ui.undo" } +export type Redo = { action: "ui.redo" } +export type TextFormat = { action: "ui.text.format"; itemIds: Id[]; format: "bold" | "italic" | "underline" } + +export type SetLocalBoard = { + action: "ui.board.setLocal" + boardId: Id | undefined + storedInitialState: LocalStorageBoard | undefined +} +export type BoardLoggedOut = { action: "ui.board.logged.out"; boardId: Id } +export type GoOffline = { action: "ui.offline" } +export type GoOnline = { action: "ui.online" } + +export const CURSOR_POSITIONS_ACTION_TYPE = "c" as const +export type CursorPositions = { action: typeof CURSOR_POSITIONS_ACTION_TYPE; p: Record } + +export const exampleBoard: Board = { + id: "default", + name: "Test Board", + items: arrayToRecordById([ + newNote("Hello", PINK, 10, 5), + newNote("World", LIGHT_BLUE, 20, 10), + newNote("Welcome", RED, 5, 14), + ]), + connections: [], + ...defaultBoardSize, + serial: 0, +} + +export function newBoard(name: string, crdt?: CrdtMode, accessPolicy?: BoardAccessPolicy): Board { + return { id: uuid.v4(), name, items: {}, accessPolicy, connections: [], ...defaultBoardSize, serial: 0, crdt } +} + +export function newNote( + text: string, + color: Color = DEFAULT_NOTE_COLOR, + x: number = 20, + y: number = 20, + width: number = 5, + height: number = 5, + shape: NoteShape = "square", + z: number = 0, +): Note { + return { id: uuid.v4(), type: "note", text, color, x, y, width, height, z, shape, locked: false } +} + +export function newSimilarNote(note: Note) { + return newNote("HELLO", note.color, 20, 20, note.width, note.height, note.shape) +} + +export function newText( + crdt: CrdtMode, + text: string = "HELLO", + x: number = 20, + y: number = 20, + width: number = 5, + height: number = 2, + z: number = 0, +): Text { + return { + id: uuid.v4(), + type: "text", + text, + x, + y, + width, + height, + z, + color: "none", + locked: false, + crdt, + } +} + +export function newContainer( + crdt: CrdtMode, + x: number = 20, + y: number = 20, + width: number = 30, + height: number = 20, + z: number = 0, +): Container { + return { + id: uuid.v4(), + type: "container", + text: "Unnamed area", + x, + y, + width, + height, + z, + color: "white", + locked: false, + crdt, + } +} + +export function newImage( + assetId: string, + x: number = 20, + y: number = 20, + width: number = 5, + height: number = 5, + z: number = 0, +): Image { + return { id: uuid.v4(), type: "image", assetId, x, y, width, height, z, locked: false } +} + +export function newVideo( + assetId: string, + x: number = 20, + y: number = 20, + width: number = 5, + height: number = 5, + z: number = 0, +): Video { + return { id: uuid.v4(), type: "video", assetId, x, y, width, height, z, locked: false } +} + +export const isBoardItemEvent = (a: AppEvent): a is BoardItemEvent => + a.action.startsWith("item.") || + a.action.startsWith("connection.") || + a.action === "board.rename" || + a.action === "board.setAccessPolicy" + +export const isPersistableBoardItemEvent = (e: any): e is PersistableBoardItemEvent => + isBoardItemEvent(e) && !["item.lock", "item.unlock"].includes(e.action) + +export const isBoardHistoryEntry = (e: AppEvent): e is BoardHistoryEntry => + isPersistableBoardItemEvent(e) && !!(e as BoardHistoryEntry).user && !!(e as BoardHistoryEntry).timestamp +export const isLocalUIEvent = (e: AppEvent): e is LocalUIEvent => e.action.startsWith("ui.") +export const isCursorMove = (e: AppEvent): e is CursorMove => e.action === "cursor.move" +export function isSameUser(a: EventUserInfo, b: EventUserInfo) { + return a.userType == b.userType && a.nickname == b.nickname +} + +export function isColoredItem(i: Item): i is ColoredItem { + return i.type === "note" || i.type === "container" || i.type === "text" +} + +export function isShapedItem(i: Item): i is ShapedItem { + return i.type === "note" +} + +export function isTextItem(i: Item): i is TextItem { + return i.type === "note" || i.type === "text" || i.type === "container" +} + +export function isNote(i: Item): i is Note { + return i.type === "note" +} + +export function isContainer(i: Item): i is Container { + return i.type === "container" +} + +export function isText(i: Item): i is Text { + return i.type === "text" +} + +export function isItem(i: Item | Point | Connection): i is Item { + return "type" in i +} + +export function getItemText(i: Item) { + if (isTextItem(i)) return i.text + return "" +} + +export function getItemBackground(i: Item) { + if (isColoredItem(i)) { + return i.color || "white" // Default for legacy containers + } + return "none" +} + +export function getItemShape(i: Item) { + return i.type === "note" && i.shape ? i.shape : "rect" +} + +type NamespacedEvent = T extends { action: `${Namespace}.${string}` } + ? T + : never + +export function actionNamespaceIs( + ns: Namespace, + a: AppEvent, +): a is NamespacedEvent { + return a.action.startsWith(ns + ".") +} + +export function getItemIds(e: BoardHistoryEntry | PersistableBoardItemEvent): Id[] { + switch (e.action) { + case "item.front": + case "item.delete": + case "item.font.decrease": + case "item.font.increase": + return e.itemIds + case "item.move": + return e.items.map((i) => i.id) + case "item.update": + return e.items.map((i) => i.id) + case "item.add": + return e.items.map((i) => i.id) + case "item.bootstrap": + return Object.keys(e.items) + case "board.rename": + case "board.setAccessPolicy": + case "connection.add": + case "connection.modify": + case "connection.delete": + return [] + } +} + +export const getItem = (boardOrItems: Board | Record) => (id: Id) => { + const item = findItem(boardOrItems)(id) + if (!item) throw Error("Item not found: " + id) + return item +} + +export const getConnection = (b: Board) => (id: Id) => { + const conn = b.connections.find((c) => c.id === id) + if (!conn) throw Error("Connection not found: " + id) + return conn +} + +export const findItem = (boardOrItems: Board | Record) => (id: Id): Item | null => { + const items = getItems(boardOrItems) + const item = items[id] + return item || null +} + +export const findConnection = (board: Board) => (id: Id) => { + const conn = board.connections.find((c) => c.id === id) + return conn || null +} + +export function findItemIdsRecursively(ids: Id[], board: Board): Set { + const recursiveIds = new Set() + const addIdRecursively = (id: Id) => { + recursiveIds.add(id) + Object.values(board.items).forEach((i) => i.containerId === id && addIdRecursively(i.id)) + } + ids.forEach(addIdRecursively) + return recursiveIds +} + +export function findItemsRecursively(ids: Id[], board: Board): Item[] { + const recursiveIds = findItemIdsRecursively(ids, board) + return [...recursiveIds].map(getItem(board)) +} + +export const isContainedBy = (boardOrItems: Board | Record, parentCandidate: Item) => ( + i: Item, +): boolean => { + if (!i.containerId) return false + if (i.containerId === parentCandidate!.id) return true + const itemsOnBoard = getItems(boardOrItems) + const parent = findItem(itemsOnBoard)(i.containerId) + if (i.containerId === i.id) throw Error("Self-contained") + if (parent == i) throw Error("self parent") + if (!parent) return false // Don't fail here, because when folding create+move, the action is run in an incomplete board context + return isContainedBy(boardOrItems, parentCandidate)(parent) +} + +const isBoard = (u: unknown): u is Board => typeof u === "object" && !!u && "items" in u + +const getItems = (boardOrItems: Board | Record) => + isBoard(boardOrItems) ? boardOrItems.items : boardOrItems + +export function isBoardEmpty(board: Board) { + return board.connections.length === 0 && Object.values(board.items).length === 0 +} + +export function getBoardAttributes(board: Board, userInfo?: EventUserInfo): BoardAttributes { + const accessPolicy = board.accessPolicy + ? userInfo && userInfo.userType === "authenticated" + ? board.accessPolicy + : { ...board.accessPolicy, allowList: [] } // Anonymize access policy for anonymous users + : undefined + return { + id: board.id, + name: board.name, + width: board.width, + height: board.height, + accessPolicy, + } +} + +export const BOARD_ITEM_BORDER_MARGIN = 0.5 + +export function checkBoardAccess(accessPolicy: BoardAccessPolicy | undefined, userInfo: SessionUserInfo): AccessLevel { + if (!accessPolicy) return "read-write" + let accessLevel: AccessLevel = accessPolicy.publicWrite + ? "read-write" + : accessPolicy.publicRead + ? "read-only" + : "none" + if (userInfo.userType === "unidentified" || userInfo.userType === "system") { + return accessLevel + } + const email = userInfo.email + const domain = userInfo.domain + + const defaultAccess = "read-write" + for (let entry of accessPolicy.allowList) { + const nextLevel = + "email" in entry && entry.email === email + ? entry.access || defaultAccess + : "domain" in entry && domain === entry.domain + ? entry.access || defaultAccess + : "none" + accessLevel = combineAccessLevels(accessLevel, nextLevel) + } + return accessLevel +} + +function combineAccessLevels(a: AccessLevel, b: AccessLevel): AccessLevel { + if (a === "admin" || b === "admin") return "admin" + if (a === "read-write" || b === "read-write") return "read-write" + if (a === "read-only" || b === "read-only") return "read-only" + return "none" +} + +export function canRead(a: AccessLevel) { + return a !== "none" +} + +export function canWrite(a: AccessLevel) { + return a === "read-write" || a === "admin" +} + +export type Align = "TL" | "TC" | "TR" | "ML" | "MC" | "MR" | "BL" | "BC" | "BR" + +export function getAlign(item: TextItem) { + return item.align ?? (isNote(item) ? "MC" : "TL") +} + +export type HorizontalAlign = "left" | "center" | "right" + +export function getHorizontalAlign(align: Align): HorizontalAlign { + switch (align) { + case "TL": + case "ML": + case "BL": + return "left" + case "TC": + case "MC": + case "BC": + return "center" + case "TR": + case "MR": + case "BR": + return "right" + } + console.log("Unknown align", align) + return "center" +} + +export type VerticalAlign = "top" | "middle" | "bottom" + +export function getVerticalAlign(align: Align): VerticalAlign { + switch (align) { + case "TL": + case "TC": + case "TR": + return "top" + case "ML": + case "MC": + case "MR": + return "middle" + case "BL": + case "BC": + case "BR": + return "bottom" + } + console.log("Unknown align", align) + return "middle" +} + +export function setHorizontalAlign(item: I, a: HorizontalAlign): ItemUpdate { + const letter = a === "left" ? "L" : a === "center" ? "C" : "R" + const align = `${getAlign(item)[0]}${letter}` + return { id: item.id, align } as ItemUpdate +} + +export function setVerticalAlign(item: I, a: VerticalAlign): ItemUpdate { + const letter = a === "top" ? "T" : a === "middle" ? "M" : "B" + const align = `${letter}${getAlign(item)[1]}` + return { id: item.id, align } as ItemUpdate +} diff --git a/common/src/geometry.ts b/common/src/geometry.ts new file mode 100644 index 0000000..9cc18a4 --- /dev/null +++ b/common/src/geometry.ts @@ -0,0 +1,71 @@ +import { Point } from "./domain" +export const origin = { x: 0, y: 0 } +export type Coordinates = { x: number; y: number } +export type Dimensions = { width: number; height: number } +export type Rect = { x: number; y: number; width: number; height: number } +export const ZERO_RECT = { x: 0, y: 0, height: 0, width: 0 } +export function add(a: Coordinates, b: Coordinates) { + return { x: a.x + b.x, y: a.y + b.y } +} + +export function subtract(a: Coordinates, b: Coordinates) { + return { x: a.x - b.x, y: a.y - b.y } +} + +export function negate(a: Coordinates) { + return { x: -a.x, y: -a.y } +} + +export function multiply(a: Coordinates, factor: number) { + return { x: a.x * factor, y: a.y * factor } +} + +export function overlaps(a: Rect, b: Rect) { + if (b.x >= a.x + a.width) return false + if (b.x + b.width <= a.x) return false + if (b.y >= a.y + a.height) return false + if (b.y + b.height <= a.y) return false + return true +} + +export function equalRect(a: Rect, b: Rect) { + return a.x == b.x && a.y == b.y && a.width == b.width && a.height == b.height +} + +export function distance(a: Coordinates, b: Coordinates) { + return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)) +} + +export function containedBy(a: Point, b: Rect): boolean +export function containedBy(a: Rect, b: Rect): boolean +export function containedBy(a: Rect | Point, b: Rect) { + if ("width" in a) { + 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 + } else { + return a.x > b.x && a.y > b.y && a.x < b.x + b.width && a.y < b.y + b.height + } +} + +export function rectFromPoints(a: Coordinates, b: Coordinates) { + const x = Math.min(a.x, b.x) + const y = Math.min(a.y, b.y) + + const width = Math.abs(a.x - b.x) + const height = Math.abs(a.y - b.y) + + return { x, y, width, height } +} + +export function isRect(i: Point | Rect): i is Rect { + return "width" in i +} + +export function centerPoint(i: Point | Rect) { + if (isRect(i)) { + return { + x: i.x + i.width / 2, + y: i.y + i.height / 2, + } + } + return i +} diff --git a/common/src/migration.test.ts b/common/src/migration.test.ts new file mode 100644 index 0000000..5f5e285 --- /dev/null +++ b/common/src/migration.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest" +import { arrayToRecordById } from "./arrays" +import { + AddConnection, + AddItem, + Board, + Connection, + ConnectionEndPoint, + DeleteConnection, + DeleteItem, + ModifyConnection, + MoveItem, + defaultBoardSize, + exampleBoard, + newISOTimeStamp, +} from "./domain" +import { migrateBoard, migrateEvent } from "./migration" + +describe("Migration", () => { + describe("Migrate board", () => { + it("Migrates boards correctly", () => { + const containedNoteWithNoType = { + id: "a", + x: 1, + y: 1, + width: 1, + height: 1, + text: "note a", + color: "yellow", + } + const containedNote2 = { + type: "note", + id: "b", + x: 2, + y: 2, + width: 1, + height: 1, + text: "note b", + color: "yellow", + } + const unContainedNoteWithNoDimensions = { + type: "note", + id: "c", + x: 3, + y: 3, + text: "note c", + color: "yellow", + } + const oldFormContainerWithItemsAndNoText = { + type: "container", + id: "d", + x: 0, + y: 0, + width: 5, + height: 5, + items: ["a", "b"], + } + + const legacyBoard: any = { + id: "foo", + name: + "board with no size, where containers have items property and items do not have containerId property", + items: [ + containedNoteWithNoType, + containedNote2, + unContainedNoteWithNoDimensions, + oldFormContainerWithItemsAndNoText, + ], + } + + const board = migrateBoard(legacyBoard) + + expect(board).toEqual({ + ...legacyBoard, + ...defaultBoardSize, + connections: [], + items: arrayToRecordById([ + { ...containedNoteWithNoType, type: "note", containerId: "d", z: 0, locked: false }, + { ...containedNote2, containerId: "d", z: 0, locked: false }, + { ...unContainedNoteWithNoDimensions, width: 5, height: 5, z: 0, locked: false }, + { type: "container", id: "d", x: 0, y: 0, width: 5, height: 5, z: 0, text: "", locked: false }, + ]), + }) + }) + it("Removes broken connections", () => { + const borkenEndpoint: ConnectionEndPoint = { id: "asdf", side: "bottom" } + + const b: Board = { + ...exampleBoard, + connections: [ + { + from: borkenEndpoint, + to: borkenEndpoint, + id: "asfdoi", + controlPoints: [], + fromStyle: "none", + toStyle: "none", + pointStyle: "none", + action: "connect", + locked: false, + }, + ], + } + + expect(migrateBoard(b)).toEqual(exampleBoard) + }) + + it("Sets connection end styles", () => { + const b: Board = { + ...exampleBoard, + connections: [{ from: { x: 0, y: 0 }, to: { x: 0, y: 0 }, id: "asfdoi", controlPoints: [] } as any], + } + + expect(migrateBoard(b)).toEqual({ + ...exampleBoard, + connections: [ + { + from: { x: 0, y: 0 }, + to: { x: 0, y: 0 }, + id: "asfdoi", + controlPoints: [], + fromStyle: "black-dot", + toStyle: "arrow", + pointStyle: "black-dot", + action: "connect", + locked: false, + } as Connection, + ], + }) + }) + }) + describe("Migrate event", () => { + const headers = { + user: { userType: "unidentified", nickname: "asdf" }, + timestamp: newISOTimeStamp(), + boardId: "", + } + it("connection.add", () => { + const connection = { from: "a", to: "b", controlPoints: [], id: "c" } + expect( + (migrateEvent({ + ...headers, + action: "connection.add", + connection, + } as any) as AddConnection).connections, + ).toEqual([connection]) + expect( + (migrateEvent({ + ...headers, + action: "connection.add", + connection: [connection], + } as any) as AddConnection).connections, + ).toEqual([connection]) + }) + + it("connection.modify", () => { + const connection = { from: "a", to: "b", controlPoints: [], id: "c" } + expect( + (migrateEvent({ + ...headers, + action: "connection.modify", + connection, + } as any) as ModifyConnection).connections, + ).toEqual([connection]) + expect( + (migrateEvent({ + ...headers, + action: "connection.modify", + connection: [connection], + } as any) as ModifyConnection).connections, + ).toEqual([connection]) + }) + + it("connection.delete", () => { + const connectionId = "c" + expect( + (migrateEvent({ + ...headers, + action: "connection.delete", + connectionId, + } as any) as DeleteConnection).connectionIds, + ).toEqual([connectionId]) + expect( + (migrateEvent({ + ...headers, + action: "connection.delete", + connectionId: [connectionId], + } as any) as DeleteConnection).connectionIds, + ).toEqual([connectionId]) + }) + + it("item.move", () => { + expect( + (migrateEvent({ + ...headers, + action: "item.move", + items: [], + } as any) as MoveItem).connections, + ).toEqual([]) + }) + + it("item.delete", () => { + expect( + (migrateEvent({ + ...headers, + action: "item.delete", + itemIds: [], + } as any) as DeleteItem).connectionIds, + ).toEqual([]) + }) + + it("item.add", () => { + expect( + (migrateEvent({ + ...headers, + action: "item.add", + items: [], + } as any) as AddItem).connections, + ).toEqual([]) + }) + }) +}) diff --git a/common/src/migration.ts b/common/src/migration.ts new file mode 100644 index 0000000..c90b571 --- /dev/null +++ b/common/src/migration.ts @@ -0,0 +1,126 @@ +import { isArray } from "lodash" +import { arrayToRecordById, toArray } from "./arrays" +import { resolveEndpoint } from "./connection-utils" +import { Board, BoardHistoryEntry, Container, Connection, defaultBoardSize, Id, Item, Serial } from "./domain" + +export function mkBootStrapEvent(boardId: Id, snapshot: Board, serial: Serial = 1) { + return { + action: "item.bootstrap", + boardId, + items: snapshot.items, + connections: snapshot.connections, + timestamp: new Date().toISOString(), + user: { nickname: "admin", userType: "system" }, + serial, + } as BoardHistoryEntry +} + +export function migrateBoard(origBoard: Board) { + const board = { ...origBoard } + const items: Item[] = [] + const width = Math.max(board.width || 0, defaultBoardSize.width) + const height = Math.max(board.height || 0, defaultBoardSize.height) + for (const item of Object.values(board.items)) { + if (items.find((i) => i.id === item.id)) { + console.warn("Duplicate item", item, "found on table", board.name) + } else { + items.push(migrateItem(item, items, board.items)) + } + } + if (board.accessPolicy) { + if (!board.accessPolicy.allowList.some((e) => e.access === "admin")) { + console.log(`No board admin for board ${board.id} -> mapping all read-write users as admins`) + board.accessPolicy.allowList = board.accessPolicy.allowList.map((e) => ({ + ...e, + access: e.access === "read-write" ? "admin" : e.access, + })) + } + } + + const connections = (board.connections ?? []) + .filter((c) => { + try { + resolveEndpoint(c.from, board) + resolveEndpoint(c.to, board) + } catch (e) { + console.error(`Error resolving connection ${JSON.stringify(c)}`) + return false + } + return true + }) + .map(migrateConnection) + + return { ...board, connections, width, height, items: arrayToRecordById(items) } +} + +function migrateConnection(c: Connection): Connection { + c = + c.fromStyle && c.fromStyle !== ("white-dot" as any) && c.toStyle && c.pointStyle + ? c + : { ...c, fromStyle: "black-dot", toStyle: "arrow", pointStyle: "black-dot" } + c = c.action !== undefined ? c : { ...c, action: "connect" } + c = c.locked !== undefined ? c : { ...c, locked: false } + return c +} + +function migrateItem(item: Item, migratedItems: Item[], boardItems: Record): Item { + const { width, height, z, type, locked, ...rest } = item + + // Force type, width and height for all items + let fixedItem = { + type: type || "note", + width: width || 5, + height: height || 5, + z: z || 0, + locked: locked || false, + ...rest, + } as Item + if (fixedItem.type === "text") { + fixedItem.color ??= "none" + } + if (fixedItem.type === "container") { + let container = fixedItem as Container & { items?: string[] } + // Force container to have text + container.text = container.text || "" + // If container had items property, migrate each corresponding item to have containerId of that container instead + if (container.items) { + const ids = container.items + delete container.items + ids.forEach((i) => { + const containedItem = migratedItems.find((mi) => mi.id === i) || boardItems[i] + containedItem && (containedItem.containerId = container.id) + }) + } + } + + return fixedItem +} + +export function migrateEvent(event: BoardHistoryEntry): BoardHistoryEntry { + if (event.action === "connection.add") { + if (!isArray(event.connections)) { + return { ...event, connections: toArray((event as any).connection) } + } + } else if (event.action === "connection.modify") { + if (!isArray(event.connections)) { + return { ...event, connections: toArray((event as any).connection) } + } + } else if (event.action === "connection.delete") { + if (!isArray(event.connectionIds)) { + return { ...event, connectionIds: toArray((event as any).connectionId) } + } + } else if (event.action === "item.move") { + if (!event.connections) { + return { ...event, connections: [] } + } + } else if (event.action === "item.delete") { + if (!event.connectionIds) { + return { ...event, connectionIds: [] } + } + } else if (event.action === "item.add") { + if (!event.connections) { + return { ...event, connections: [] } + } + } + return event +} diff --git a/common/src/sets.ts b/common/src/sets.ts new file mode 100644 index 0000000..8d634f0 --- /dev/null +++ b/common/src/sets.ts @@ -0,0 +1,16 @@ +export function toggleInSet(item: T, set: Set) { + if (set.has(item)) { + return new Set([...set].filter((i) => i !== item)) + } + return new Set([...set].concat(item)) +} + +export function difference(setA: Set, setB: Set) { + let _difference = new Set(setA) + for (let elem of setB) { + _difference.delete(elem) + } + return _difference +} + +export const emptySet = () => new Set() diff --git a/common/src/sleep.ts b/common/src/sleep.ts new file mode 100644 index 0000000..b254733 --- /dev/null +++ b/common/src/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(undefined), ms)) +} diff --git a/common/src/vector2.ts b/common/src/vector2.ts new file mode 100644 index 0000000..1aae47f --- /dev/null +++ b/common/src/vector2.ts @@ -0,0 +1,50 @@ +export type Vector2 = { x: number; y: number } + +export function Vector2(x: number, y: number) { + return { x, y } +} + +export function getAngleRad(v: Vector2) { + const unit = withLength(v, 1) + return Math.atan2(unit.y, unit.x) +} + +export function getAngleDeg(v: Vector2) { + return radToDeg(getAngleRad(v)) +} + +export function getLength(v: Vector2) { + return Math.sqrt(v.x * v.x + v.y * v.y) +} + +export function withLength(v: Vector2, newLength: number) { + return multiply(v, newLength / getLength(v)) +} + +export function multiply(v: Vector2, multiplier: number) { + return Vector2(v.x * multiplier, v.y * multiplier) +} + +export function add(v: Vector2, other: Vector2) { + return Vector2(v.x + other.x, v.y + other.y) +} + +export function rotateRad(v: Vector2, radians: number) { + var length = getLength(v) + var currentRadians = getAngleRad(v) + var resultRadians = radians + currentRadians + var rotatedUnit = { x: Math.cos(resultRadians), y: Math.sin(resultRadians) } + return withLength(rotatedUnit, length) +} + +export function rotateDeg(v: Vector2, degrees: number) { + return rotateRad(v, degToRad(degrees)) +} + +export function degToRad(degrees: number) { + return (degrees * 2 * Math.PI) / 360 +} + +export function radToDeg(rad: number) { + return (rad * 360) / 2 / Math.PI +} diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000..350e93c --- /dev/null +++ b/cypress.json @@ -0,0 +1,6 @@ +{ + "pluginsFile": false, + "supportFile": false, + "fixturesFolder": false, + "retries": 2 +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c26cb4b --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,33 @@ +version: "3.1" + +services: + db: + image: postgres:12 + restart: always + ports: + - 13338:5432 + environment: + POSTGRES_USER: r-board + POSTGRES_PASSWORD: secret + + keycloak-db: + image: postgres:12 + restart: always + ports: + - 13339:5432 + environment: + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: secret + volumes: + - ./keycloak/keycloak-db.dump:/docker-entrypoint-initdb.d/keycloak-db.dump.sql + keycloak: + depends_on: + - keycloak-db + image: quay.io/keycloak/keycloak:22.0.5 + command: start-dev --db postgres --db-url jdbc:postgresql://keycloak-db/keycloak --db-username keycloak --db-password secret + ports: + - 8080:8080 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - DATABASE_URL=postgres://keycloak:secreto@keycloak-db:5432/keycloak diff --git a/frontend/.sassrc b/frontend/.sassrc new file mode 100644 index 0000000..8cc4283 --- /dev/null +++ b/frontend/.sassrc @@ -0,0 +1,3 @@ +{ + "includePaths": ["node_modules"] +} \ No newline at end of file diff --git a/frontend/esbuild.js b/frontend/esbuild.js new file mode 100644 index 0000000..98569a9 --- /dev/null +++ b/frontend/esbuild.js @@ -0,0 +1,106 @@ +require("dotenv").config() + +const sass = require("sass") +const path = require("path") +const fs = require("fs") +const esbuild = require("esbuild") +const rimraf = require("rimraf") +const chokidar = require("chokidar") + +const mode = process.argv[2] + +if (!mode) { + throw Error("Specify 'build' or 'watch' as argument") +} +const stubImportsPlugin = (paths) => { + return { + name: "stub-imports", + setup(build) { + const regex = new RegExp(`^(${paths.join("|")})\$`) + build.onResolve({ filter: regex }, (args) => ({ + path: args.path, + namespace: "stub-imports-namespace", + })) + build.onLoad({ filter: /.*/, namespace: "stub-imports-namespace" }, () => ({ + contents: "{}", + loader: "json", + })) + }, + } +} + +const sassPlugin = { + name: "sass", + setup(build) { + build.onResolve({ filter: /(\.svg|\.png)$/ }, (args) => { + return { + path: path.resolve(CWD, "src", args.path), + } + }) + build.onResolve({ filter: /\.scss$/ }, (args) => { + return { + path: path.resolve(args.resolveDir, args.path), + namespace: "sass", + } + }) + build.onLoad({ filter: /.*/, namespace: "sass" }, (args) => { + let compiled = sass.renderSync({ file: args.path }) + return { + contents: compiled.css.toString(), + loader: "css", + } + }) + }, +} + +const CWD = process.cwd() +const DIST_FOLDER = path.resolve(CWD, "dist") + +const envFallback = (envVar, fb = null) => (envVar ? `"${envVar}"` : `${fb}`) + +async function build() { + if (fs.existsSync(DIST_FOLDER)) rimraf.sync(DIST_FOLDER) + const randomString = Math.random().toString(36).slice(2) + const outfile = path.resolve(CWD, `dist/bundle.${randomString}.js`) + const metafile = path.resolve(CWD, `dist/bundle.${randomString}.meta.json`) + const now = Date.now() + await esbuild.build({ + entryPoints: [path.resolve(CWD, "src", "index.tsx")], + bundle: true, + minify: mode !== "watch", + outfile, + metafile, + sourcemap: true, + platform: "browser", + plugins: [sassPlugin, stubImportsPlugin(["path"])], + loader: { ".png": "file", ".svg": "file" }, + define: { + "process.env.NODE_ENV": envFallback(process.env.NODE_ENV, `"development"`), + }, + }) + + fs.writeFileSync( + path.resolve(CWD, "dist/index.html"), + fs + .readFileSync(path.resolve(CWD, "index.tmpl.html"), "utf8") + .replace("JAVASCRIPT_BUNDLE", `/bundle.${randomString}.js`) + .replace("CSS_BUNDLE", `/bundle.${randomString}.css`), + ) + console.log(`Frontend build done, took ${Date.now() - now} ms`) +} + +if (mode === "build") { + build().catch((e) => !console.error(e) && process.exit(1)) +} else if (mode === "watch") { + build() + .catch((e) => console.error(e)) + .then(() => { + chokidar + .watch([path.resolve(CWD, "src"), path.resolve(CWD, "../common/src")], { ignoreInitial: true }) + .on("all", (...arg) => { + build().catch((e) => console.error(e)) + }) + }) +} else { + throw Error("Unknown mode: " + mode) +} diff --git a/frontend/index.tmpl.html b/frontend/index.tmpl.html new file mode 100644 index 0000000..b3763c1 --- /dev/null +++ b/frontend/index.tmpl.html @@ -0,0 +1,16 @@ + + + OurBoard + + + + + + + + + + +
+ + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9fee70a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,66 @@ +{ + "name": "rboard-frontend", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.0", + "@types/email-validator": "^1.0.6", + "@types/js-cookie": "^3.0.6", + "@types/lodash": "^4.14.161", + "@types/md5": "^2.2.1", + "@types/path-to-regexp": "^1.7.0", + "@types/pretty-ms": "^5.0.1", + "@types/quill": "^2.0.14", + "@types/ramda": "^0.27.40", + "@types/sanitize-html": "^1.27.1", + "bezier-js": "^4.0.3", + "binpackingjs": "^3.0.2", + "cookie": "^0.4.1", + "email-validator": "^2.0.4", + "esbuild": "^0.8.57", + "fp-ts": "^2.9.5", + "harmaja": "^0.24", + "harmaja-router": "^0.3.3", + "io-ts": "^2.2.15", + "js-cookie": "^3.0.5", + "jsonwebtoken": "^8.5.1", + "jwt-decode": "^3.1.2", + "localforage": "^1.9.0", + "lodash": "^4.17.20", + "lonna": "^0.12.2", + "md5": "^2.3.0", + "path-to-regexp": "^6.2.0", + "pretty-ms": "^7.0.1", + "quill": "^1.3.7", + "quill-cursors": "^4.0.2", + "ramda": "^0.27.1", + "rimraf": "^3.0.2", + "sanitize-html": "^2.3.2", + "sass": "^1.32.8", + "uuid": "^8.3.0", + "y-indexeddb": "^9.0.12", + "y-quill": "^0.1.5", + "y-websocket": "^1.5.3", + "yjs": "^13.6.12" + }, + "scripts": { + "build": "node esbuild.js build", + "watch": "node esbuild.js watch", + "tsc:watch": "tsc --watch --noEmit" + }, + "devDependencies": { + "@types/jsonwebtoken": "^8.5.0", + "@types/uuid": "^8.3.0", + "chokidar": "^3.5.1", + "core-js": "^3.8.3", + "cssnano": "^4.1.10", + "dotenv": "^8.2.0", + "nodemon": "^2.0.4", + "npm-run-all": "^4.1.5", + "typescript": "^5.3" + }, + "browserslist": [ + "since 2017-06" + ] +} diff --git a/frontend/public/img/reaktor-logo.jpg b/frontend/public/img/reaktor-logo.jpg new file mode 100644 index 0000000..134cfa2 Binary files /dev/null and b/frontend/public/img/reaktor-logo.jpg differ diff --git a/frontend/src/app.scss b/frontend/src/app.scss new file mode 100644 index 0000000..e5a4de9 --- /dev/null +++ b/frontend/src/app.scss @@ -0,0 +1,10 @@ +@import "style/variables.scss"; +@import "style/global.scss"; +@import "style/dashboard.scss"; +@import "style/utils.scss"; +@import "style/board.scss"; +@import "style/tool-layer.scss"; +@import "style/header.scss"; +@import "style/modal.scss"; +@import "style/sharing-modal.scss"; +@import "style/user-info-modal.scss"; diff --git a/frontend/src/board-navigation.ts b/frontend/src/board-navigation.ts new file mode 100644 index 0000000..d04d684 --- /dev/null +++ b/frontend/src/board-navigation.ts @@ -0,0 +1,46 @@ +import { HarmajaRouter, Navigator } from "harmaja-router" +import * as L from "lonna" +import { Board, BoardStub, EventFromServer } from "../../common/src/domain" +import "./app.scss" +import { Dispatch } from "./store/server-connection" + +export const BOARD_PATH = "/b/:boardId" +export const ROOT_PATH = "/" + +export const Routes = { + [ROOT_PATH]: () => ({ page: "Dashboard" as const }), + [BOARD_PATH]: ({ boardId }: { boardId: string }) => ({ page: "Board" as const, boardId }), + "": () => ({ page: "NotFound" as const }), +} +export type Routes = typeof Routes + +export function BoardNavigation() { + const result = HarmajaRouter(Routes) + + const nicknameFromURL = new URLSearchParams(location.search).get("nickname") + if (nicknameFromURL) { + localStorage.nickname = nicknameFromURL + const search = new URLSearchParams(location.search) + search.delete("nickname") + document.location.search = search.toString() + } + + const boardId = L.view(result, (r) => (r.page === "Board" ? r.boardId : undefined)) + + return { + boardId, + page: result, + } +} + +export function createBoardAndNavigate( + newBoard: Board | BoardStub, + dispatch: Dispatch, + navigator: Navigator, + serverEvents: L.EventStream, +) { + dispatch({ action: "board.add", payload: newBoard }) + serverEvents.forEach( + (e) => e.action === "board.add.ack" && navigator.navigateByParams(BOARD_PATH, { boardId: newBoard.id }), + ) +} diff --git a/frontend/src/board/BoardView.tsx b/frontend/src/board/BoardView.tsx new file mode 100644 index 0000000..7d7ea7f --- /dev/null +++ b/frontend/src/board/BoardView.tsx @@ -0,0 +1,434 @@ +import * as H from "harmaja" +import { componentScope, h, ListView } from "harmaja" +import * as L from "lonna" +import { + Board, + canWrite, + findConnection, + findItem, + getConnection, + Id, + Image, + Item, + newNote, + Note, + Video, +} from "../../../common/src/domain" +import { isFirefox } from "../components/browser" +import { ModalContainer } from "../components/ModalContainer" +import { onClickOutside } from "../components/onClickOutside" +import { isEmbedded } from "../embedding" +import { AssetStore } from "../store/asset-store" +import { BoardState, BoardStore, Dispatch } from "../store/board-store" +import { CursorsStore } from "../store/cursors-store" +import { UserSessionState } from "../store/user-session-store" +import { boardCoordinateHelper } from "./board-coordinates" +import { boardDragHandler } from "./board-drag" +import { + BoardFocus, + getSelectedItemIds, + getSelectedItem, + getSelectedItems, + noFocus, + getSelectedConnectionIds, +} from "./board-focus" +import { boardScrollAndZoomHandler } from "./board-scroll-and-zoom" +import { BoardToolLayer } from "./toolbars/BoardToolLayer" +import { ConnectionsView } from "./ConnectionsView" +import { ContextMenuView } from "./contextmenu/ContextMenuView" +import { CursorsView } from "./CursorsView" +import * as G from "../../../common/src/geometry" +import { imageUploadHandler, imageDropHandler } from "./image-upload" +import { ImageView } from "./ImageView" +import { itemCreateHandler } from "./item-create" +import { cutCopyPasteHandler } from "./item-cut-copy-paste" +import { itemDeleteHandler } from "./item-delete" +import { itemDuplicateHandler } from "./item-duplicate" +import { itemMoveWithArrowKeysHandler } from "./item-move-with-arrow-keys" +import { itemSelectAllHandler } from "./item-select-all" +import { withCurrentContainer } from "./item-setcontainer" +import { itemUndoHandler } from "./item-undo-redo" +import { ItemView } from "./ItemView" +import { installKeyboardShortcut, plainKey } from "./keyboard-shortcuts" +import { RectangularDragSelection } from "./RectangularDragSelection" +import { SelectionBorder } from "./SelectionBorder" +import { synchronizeFocusWithServer } from "./synchronize-focus-with-server" +import { ToolController } from "./tool-selection" +import { BoardViewHeader } from "./header/BoardViewHeader" +import { VideoView } from "./VideoView" +import { startConnecting } from "./item-connect" +import { emptySet } from "../../../common/src/sets" +import { installZoomKeyboardShortcuts } from "./zoom-shortcuts" +import { itemHideContentsHandler } from "./item-hide-contents" + +const emptyNote = newNote("") + +export const BoardView = ({ + boardId, + cursors, + boardStore, + sessionState, + assets, + dispatch, +}: { + boardId: string + cursors: CursorsStore + boardStore: BoardStore + sessionState: L.Property + assets: AssetStore + dispatch: Dispatch +}) => { + const boardState = boardStore.state + const board = boardState.pipe( + L.map((s: BoardState) => s.board!), + L.filter((b: Board) => !!b, componentScope()), + ) + const accessLevel = L.view(boardState, "accessLevel") + const locks = L.view(boardState, (s) => s.locks) + const sessionId = L.view(sessionState, (s) => s.sessionId) + const sessions = L.view(boardState, (s) => s.users) + const zoom = L.atom({ zoom: 1, quickZoom: 1 }) + + const containerElement = L.atom(null) + const scrollElement = L.atom(null) + const boardElement = L.atom(null) + const latestNoteId = L.atom(null) + const latestNote = L.view(latestNoteId, board, (id, b) => { + const note = id ? findItem(b)(id) : null + return (note as Note) || emptyNote + }) + const latestConnectionId = L.atom(null) + const latestConnection = L.view(latestConnectionId, board, (id, b) => { + return id ? findConnection(b)(id) : null + }) + const focus = synchronizeFocusWithServer(board, locks, sessionId, dispatch) + const coordinateHelper = boardCoordinateHelper(containerElement, scrollElement, boardElement, zoom) + const toolController = ToolController() + const tool = toolController.tool + + let previousFocus: BoardFocus | null = null + focus.forEach((f) => { + const previousIDs = previousFocus && getSelectedItemIds(previousFocus) + const itemIds = [...getSelectedItemIds(f)].filter((id) => !previousIDs || !previousIDs.has(id)) + previousFocus = f + if (itemIds.length > 0) { + dispatch({ action: "item.front", boardId: board.get().id, itemIds }) + const item = getSelectedItem(board.get())(f) + if (item && item.type === "note") { + latestNoteId.set(item.id) + } + } + const connectionId = [...getSelectedConnectionIds(f)][0] + if (connectionId && getConnection(board.get())(connectionId)?.action === "connect") { + latestConnectionId.set(connectionId) + } + }) + + tool.pipe(L.changes).forEach((tool) => { + if (tool !== "note" && tool !== "container" && tool !== "text" && focus.get().status === "adding") { + focus.set(noFocus) + } + }) + + const doOnUnmount: Function[] = [] + + const itemsList = L.view(L.view(board, "items"), Object.values) + + function onURL(assetId: string, url: string) { + itemsList.get().forEach((i) => { + if ((i.type === "image" || i.type === "video") && i.assetId === assetId && i.src != url) { + dispatch({ action: "item.update", boardId, items: [{ id: i.id, src: url }] }) + } + }) + } + const uploadImageFile = imageUploadHandler(assets, coordinateHelper, onAdd, onURL) + + doOnUnmount.push( + cutCopyPasteHandler(board, boardStore.crdtStore, focus, coordinateHelper, dispatch, uploadImageFile), + ) + + const zoomControls = boardScrollAndZoomHandler( + board, + boardElement, + scrollElement, + zoom, + coordinateHelper, + toolController, + ) + const { viewRect } = zoomControls + + boardStore.eventsFromServer.forEach((e) => { + if (e.action === "user.bringAllToMe") { + console.log(`Following user ${e.nickname}`, e) + viewRect.set(e.viewRect) + } + }) + + imageDropHandler(boardElement, assets, focus, uploadImageFile) + itemCreateHandler(board, focus, latestNote, boardElement, onAdd) + itemDeleteHandler(boardId, dispatch, focus) + itemDuplicateHandler(board, boardStore.crdtStore, dispatch, focus) + itemHideContentsHandler(board, focus, dispatch) + itemMoveWithArrowKeysHandler(board, dispatch, focus) + itemUndoHandler(dispatch) + itemSelectAllHandler(board, focus) + installZoomKeyboardShortcuts(zoomControls) + installKeyboardShortcut( + (e) => e.key === "Escape", + () => { + toolController.useDefaultTool() + focus.set(noFocus) + }, + ) + installKeyboardShortcut(plainKey("c"), () => toolController.tool.set("connect")) + installKeyboardShortcut(plainKey("l"), () => toolController.tool.set("line")) + L.fromEvent(window, "click") + .pipe(L.applyScope(componentScope())) + .forEach((event) => { + if (!boardElement.get()!.contains(event.target as Node)) { + // Click outside => reset selection + focus.set(noFocus) + } + }) + + onClickOutside(boardElement, () => { + focus.set(noFocus) + }) + + function onClick(e: JSX.UIEvent) { + const f = focus.get() + if (f.status === "connection-adding") { + toolController.useDefaultTool() + } else if (f.status === "adding") { + onAdd(f.item) + } else { + if (e.target === boardElement.get()) { + if (tool.get() === "connect") { + startConnecting( + board, + coordinateHelper, + latestConnection, + dispatch, + toolController, + focus, + coordinateHelper.currentBoardCoordinates.get(), + ) + } else { + focus.set(noFocus) + } + } + } + } + + function onAdd(item: Item) { + toolController.useDefaultTool() + const point = coordinateHelper.currentBoardCoordinates.get() + const { x, y } = item.type !== "container" ? G.add(point, { x: -item.width / 2, y: -item.height / 2 }) : point + item = withCurrentContainer({ ...item, x, y }, board.get()) + + dispatch({ action: "item.add", boardId, items: [item], connections: [] }) + + if (item.type === "note" || item.type === "text") { + focus.set({ status: "editing", itemId: item.id }) + } else { + focus.set({ status: "selected", itemIds: new Set([item.id]), connectionIds: emptySet() }) + } + } + + coordinateHelper.currentBoardCoordinates.pipe(L.throttle(30)).forEach((position) => { + dispatch({ action: "cursor.move", position, boardId }) + }) + + const { selectionRect } = boardDragHandler({ + ...{ + board, + boardElem: boardElement, + coordinateHelper, + latestConnection, + focus, + toolController, + dispatch, + }, + }) + + H.onUnmount(() => { + doOnUnmount.forEach((fn) => fn()) + }) + + const boardAccessStatus = L.view(boardState, (s) => s.status) + const quickZoom = L.view(zoom, "quickZoom") + const mainZoom = L.view(zoom, "zoom") + const borderContainerStyle = L.combineTemplate({ + width: L.view(board, quickZoom, (b) => b.width + "em"), + height: L.view(board, quickZoom, (b) => b.height + "em"), + fontSize: L.view(mainZoom, (z) => z + "em"), + transform: L.view(quickZoom, (z) => { + const percentTranslate = ((z - 1) / 2) * 100 + return `translate(${percentTranslate}%, ${percentTranslate}%) scale(${z})` + }), + "will-change": "transform, fontSize", + }) + + const className = L.view( + boardAccessStatus, + (status) => `board-container ${isEmbedded() ? "embedded" : ""} ${status}`, + ) + + const items = L.view(L.view(board, "items"), Object.values, (items) => items.filter((i) => !i.hidden)) + const selectedItems = L.view(board, focus, (b, f) => getSelectedItems(b)(f)) + const modalContent = L.atom(null) + + L.interval(100, null, componentScope()).forEach(() => { + if (window.scrollY !== 0 || window.scrollX !== 0) { + if (focus.get().status !== "editing") { + // Reset scroll position when not editing. At least iOS seems to need this. It's vital that our scroll pos stays at origin. + window.scrollTo(0, 0) + } + } + }) + return ( +
+ + s.status === "online"), + crdtStore: boardStore.crdtStore, + }} + /> +
+
+
+
"board " + t)} + draggable={isFirefox ? L.view(focus, (f) => f.status !== "editing") : true} + ref={boardElement.set} + onClick={onClick} + onTouchEnd={onClick} + > + i.id} /> + + {L.view(tool, (t) => + t === "connect" ? null : ( + i.id} + /> + ), + )} + + + + +
+
+
+ +
+
+ ) + + function renderSelectionBorder(id: string, item: L.Property) { + return + } + + function renderItem(id: string, item: L.Property) { + const isLocked = L.combineTemplate({ locks, sessionId }).pipe( + L.map(({ locks, sessionId }) => !!locks[id] && locks[id] !== sessionId), + ) + + return L.view(L.view(item, "type"), (t) => { + switch (t) { + case "container": + case "text": + case "note": + return ( + , + isLocked, + focus, + coordinateHelper, + latestConnection, + dispatch, + toolController, + accessLevel, + boardStore, + }} + /> + ) + case "image": + return ( + , + assets, + board, + isLocked, + focus, + toolController, + coordinateHelper, + latestConnection, + dispatch, + }} + /> + ) + case "video": + return ( + , + assets, + board, + isLocked, + focus, + toolController, + coordinateHelper, + latestConnection, + dispatch, + }} + /> + ) + default: + throw Error("Unsupported item: " + t) + } + }) + } +} diff --git a/frontend/src/board/BoardViewMessage.tsx b/frontend/src/board/BoardViewMessage.tsx new file mode 100644 index 0000000..c53d037 --- /dev/null +++ b/frontend/src/board/BoardViewMessage.tsx @@ -0,0 +1,60 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { Board } from "../../../common/src/domain" +import { signIn } from "../google-auth" +import { BoardAccessStatus } from "../store/board-store" +import { UserSessionState } from "../store/user-session-store" + +export const BoardViewMessage = ({ + boardAccessStatus, + sessionState, + board, +}: { + boardAccessStatus: L.Property + sessionState: L.Property + board: L.Property +}) => { + // TODO: login may be disabled due to Incognito mode or other reasons + return L.combine(boardAccessStatus, L.view(sessionState, "status"), (s: BoardAccessStatus, sessionStatus) => { + if (s === "not-found") { + return ( +
+
+

Board not found. A typo, maybe?

+
+
+ ) + } + if (s === "denied-permanently") { + return ( +
+
+

+ Sorry, access denied. Click here to sign in with another account. +

+
+
+ ) + } + if (s === "login-required") { + if (sessionStatus === "login-failed") { + return ( +
+
+ Something went wrong with logging in. Click here to try again. +
+
+ ) + } + + return ( +
+
+ This board is for authorized users only. Click here to sign in. +
+
+ ) + } + return null + }) +} diff --git a/frontend/src/board/CollaborativeTextView.tsx b/frontend/src/board/CollaborativeTextView.tsx new file mode 100644 index 0000000..ce11f6e --- /dev/null +++ b/frontend/src/board/CollaborativeTextView.tsx @@ -0,0 +1,166 @@ +import { componentScope, h } from "harmaja" +import * as L from "lonna" +import Quill from "quill" +import QuillCursors from "quill-cursors" +import { QuillBinding } from "y-quill" +import { + AccessLevel, + Board, + LocalUIEvent, + TextItem, + canWrite, + getAlign, + getHorizontalAlign, + getItemBackground, +} from "../../../common/src/domain" +import { CRDTStore } from "../store/crdt-store" +import { getAlignItems } from "./ItemView" +import { BoardFocus } from "./board-focus" +import { contrastingColor } from "./contrasting-color" +import { preventDoubleClick } from "./double-click" +import PasteLinkOverText from "./quillPasteLinkOverText" +import ClickableLink from "./quillClickableLink" + +Quill.register("modules/cursors", QuillCursors) +Quill.register("modules/pasteLinkOverText", PasteLinkOverText) +Quill.register(ClickableLink) + +interface CollaborativeTextViewProps { + item: L.Property + board: L.Property + id: string + accessLevel: L.Property + focus: L.Atom + itemFocus: L.Property<"none" | "selected" | "dragging" | "editing"> + crdtStore: CRDTStore + isLocked: L.Property + uiEvents: L.EventStream +} +export function CollaborativeTextView({ + id, + item, + board, + accessLevel, + focus, + itemFocus, + isLocked, + crdtStore, + uiEvents, +}: CollaborativeTextViewProps) { + const fontSize = L.view(item, (i) => `${i.fontSize ? i.fontSize : 1}em`) + const color = L.view(item, getItemBackground, contrastingColor) + + const quillEditor = L.atom(null) + + accessLevel.applyScope(componentScope()).forEach((al) => { + quillEditor.get()?.enable(canWrite(al)) + }) + + function initQuill(el: HTMLElement) { + const quill = new Quill(el, { + modules: { + cursors: true, + toolbar: false, + pasteLinkOverText: true, + history: { + userOnly: true, // Local undo shouldn't undo changes from remote users + }, + }, + theme: "snow", + readOnly: !canWrite(accessLevel.get()), + }) + const crdt = crdtStore.getBoardCrdt(board.get().id) + const ytext = crdt.getField(id, "text") + new QuillBinding(ytext, quill, crdt.awareness) + quillEditor.set(quill) + } + + const editingThis = L.view(itemFocus, (f) => f === "editing") + + editingThis.forEach((e) => { + const q = quillEditor.get() + if (q) { + if (e) { + const multipleLines = + q + .getText() + .split("\n") + .filter((x) => x).length > 1 + if (!multipleLines) { + // For one-liners, select the whole text on double click + q.setSelection(0, 1000000) + } + } else { + // Clear text selecting when not editing + q.setSelection(null as any) + } + } + }) + + function handleClick() { + if (itemFocus.get() === "selected") { + focus.set({ status: "editing", itemId: id }) + } + } + + const pointerEvents = L.view(itemFocus, isLocked, (f, l) => + f === "editing" || f === "selected" || l ? "auto" : "none", + ) + const hAlign = L.view(item, getAlign, getHorizontalAlign).applyScope(componentScope()) + hAlign.onChange((align) => { + quillEditor.get()?.formatText(0, 10000000, "align", align === "left" ? "" : align) + }) + + uiEvents.applyScope(componentScope()).forEach((e) => { + if (e.action === "ui.text.format" && e.itemIds.includes(id)) { + const quill = quillEditor.get() + const selection = quill?.getSelection() + const format = selection && quill?.getFormat(selection) + const newValue = !(format && format[e.format]) + quill?.format(e.format, newValue) + } + }) + + let touchMoves = 0 + + return ( +
{ + e.stopPropagation() + if (e.key === "Escape") { + focus.set({ status: "selected", itemIds: new Set([id]), connectionIds: new Set() }) + } + }} + onKeyDown={(e) => { + e.stopPropagation() + }} + onKeyPress={(e) => { + e.stopPropagation() + }} + onDoubleClick={(e) => { + e.stopPropagation() + quillEditor.get()?.focus() + }} + onTouchStart={(e) => { + preventDoubleClick(e) + touchMoves = 0 + }} + onTouchMove={() => touchMoves++} + onTouchEnd={() => { + if (touchMoves === 0) { + // This is a way to detect a tap (vs swipe) + quillEditor.get()?.focus() + } + }} + onClick={handleClick} + style={L.combineTemplate({ alignItems: L.view(item, getAlignItems) })} + > +
+
+ ) +} diff --git a/frontend/src/board/ConnectionsView.tsx b/frontend/src/board/ConnectionsView.tsx new file mode 100644 index 0000000..74e30c2 --- /dev/null +++ b/frontend/src/board/ConnectionsView.tsx @@ -0,0 +1,256 @@ +import { Fragment, h, ListView } from "harmaja" +import * as L from "lonna" +import { findAttachmentLocation, resolveItemEndpoint } from "../../../common/src/connection-utils" +import { + AttachmentLocation, + Board, + ConnectionEndPoint, + ConnectionEndStyle, + isDirectedItemEndPoint, + Item, + Point, +} from "../../../common/src/domain" +import { Dispatch } from "../store/board-store" +import { BoardCoordinateHelper } from "./board-coordinates" +import { BoardFocus, getSelectedConnectionIds } from "./board-focus" +import { existingConnectionHandler } from "./item-connect" +import { Z_CONNECTIONS } from "./zIndices" + +export const ConnectionsView = ({ + board, + dispatch, + zoom, + coordinateHelper, + focus, +}: { + board: L.Property + dispatch: Dispatch + zoom: L.Property + coordinateHelper: BoardCoordinateHelper + focus: L.Atom +}) => { + // Item position might change but connection doesn't -- need to rerender connections anyway + // Connection objects normally only hold the ID to the "from" and "to" items + // This populates the actual object in place of the ID + + function determineAttachmenLocation( + e: ConnectionEndPoint, + control: Point, + is: Record, + ): AttachmentLocation { + if (isDirectedItemEndPoint(e)) { + return findAttachmentLocation(resolveItemEndpoint(e, is), e.side) + } + const fromItem: Point = resolveEndpoint(e, is) + // Support legacy routing (side not fixed) + return findNearestAttachmentLocationForConnectionNode(fromItem, control) + } + const connectionsWithItemsPopulated = L.view( + L.view(board, (b) => ({ is: b.items, cs: b.connections })), + focus, + L.view(zoom, "zoom"), + ({ is, cs }, f, z) => { + return cs + .filter((c) => !c.hidden) + .map((c) => { + const fromItem: Point = resolveEndpoint(c.from, is) + const toItemOrPoint = resolveEndpoint(c.to, is) + const firstControlPoint = c.controlPoints[0] || fromItem + const lastControlPoint = c.controlPoints[c.controlPoints.length - 1] || toItemOrPoint + return { + ...c, + from: determineAttachmenLocation(c.from, firstControlPoint, is), + to: determineAttachmenLocation(c.to, lastControlPoint, is), + selected: getSelectedConnectionIds(f).has(c.id), + } + }) + }, + ) + + // We want to render round draggable nodes at the end of edges (paths), + // But SVG elements are not draggable by default, so get a flat list of + // nodes and render them as regular HTML elements + const connectionNodes = L.view(connectionsWithItemsPopulated, (cs) => + cs.flatMap((c) => [ + { id: c.id, type: "from" as const, node: c.from, selected: c.selected, style: c.fromStyle }, + { id: c.id, type: "to" as const, node: c.to, selected: c.selected, style: c.toStyle }, + ...c.controlPoints.map((cp) => ({ + id: c.id, + type: "control" as const, + node: { point: cp, side: "none" as const }, + selected: c.selected, + style: c.pointStyle, + })), + ]), + ) + + const svgElementStyle = L.combineTemplate({ + width: L.view(board, (b) => b.width + "em"), + height: L.view(board, (b) => b.height + "em"), + position: "absolute", + top: 0, + left: 0, + pointerEvents: "none", + zIndex: Z_CONNECTIONS, + }) + + return ( + <> + + observable={connectionNodes} + renderObservable={ConnectionNode} + getKey={(c) => c.id + c.type} + /> + + { + const curve = L.combine( + L.view(conn, "from"), + L.view(conn, "to"), + L.view(conn, "controlPoints"), + (from, to, cps) => { + return quadraticCurveSVGPath( + { + x: coordinateHelper.emToBoardPx(from.point.x), + y: coordinateHelper.emToBoardPx(from.point.y), + }, + { + x: coordinateHelper.emToBoardPx(to.point.x), + y: coordinateHelper.emToBoardPx(to.point.y), + }, + cps.map((cp) => ({ + x: coordinateHelper.emToBoardPx(cp.x), + y: coordinateHelper.emToBoardPx(cp.y), + })), + ) + }, + ) + return ( + + (c.selected ? "connection selected" : "connection"))} + d={curve} + > + + ) + }} + getKey={(c) => c.id} + /> + + + ) + + type ConnectionNodeProps = { + id: string + node: AttachmentLocation + type: "to" | "from" | "control" + style: ConnectionEndStyle + selected: boolean + } + function ConnectionNode(key: string, cNode: L.Property) { + function onRef(el: HTMLDivElement) { + const { id, type } = cNode.get() + existingConnectionHandler(el, id, type, coordinateHelper, board, dispatch) + } + + const id = L.view(cNode, (cn) => `connection-${cn.id}-${cn.type}`) + + const angle = L.view(cNode, (cn) => { + if (cn.style !== "arrow" || cn.type === "control") return null + const conn = connectionsWithItemsPopulated.get().find((c) => c.id === cn.id) + if (!conn) { + return null + } + const [thisEnd, otherEnd] = cn.type === "from" ? [conn.from, conn.to] : [conn.to, conn.from] + const bez = bezierCurveFromPoints( + otherEnd.point, + getControlPoint(otherEnd.point, thisEnd.point, conn.controlPoints), + thisEnd.point, + ) + const derivative = bez.derivative(1) // tangent vector at the very end of the curve + const angleInDegrees = + ((Math.atan2(derivative.y, derivative.x) - Math.atan2(0, Math.abs(derivative.x))) * 180) / Math.PI + return Math.round(angleInDegrees) + }) + + const wrapperStyle = L.view(cNode, (cn) => ({ + top: `${cn.node.point.y}em`, + left: `${cn.node.point.x}em`, + zIndex: Z_CONNECTIONS + 1, + })) + + const nodeStyle = L.view(angle, (ang) => ({ + transform: ang !== null ? `rotate(${ang}deg)` : undefined, + })) + + const selectThisConnection = (e: JSX.MouseEvent) => { + const id = cNode.get().id + const f = focus.get() + if (e.shiftKey && f.status === "selected") { + focus.set({ ...f, connectionIds: toggleInSet(id, f.connectionIds) }) + } else { + focus.set({ status: "selected", connectionIds: new Set([id]), itemIds: emptySet() }) + } + } + + return ( +
+
{ + let cls = `connection-node ${cn.type}-node ${cn.style}-style ` + + if (cn.selected) cls += "highlight " + + cls += cn.node.side === "none" ? "unattached" : "attached" + + return cls + })} + style={nodeStyle} + >
+
+ ) + } +} + +// @ts-ignore +import { Bezier } from "bezier-js" +import { findNearestAttachmentLocationForConnectionNode, resolveEndpoint } from "../../../common/src/connection-utils" +import { emptySet, toggleInSet } from "../../../common/src/sets" +import { BoardZoom } from "./board-scroll-and-zoom" + +function quadraticCurveSVGPath(from: Point, to: Point, controlPoints: Point[]) { + if (!controlPoints.length) { + // fallback if no control points: straight line + const midPoint = getControlPoint(from, to, controlPoints) + return "M" + from.x + " " + from.y + " Q " + midPoint.x + " " + midPoint.y + " " + to.x + " " + to.y + } else { + const peakPointOfCurve = controlPoints[0] + const bez = bezierCurveFromPoints(from, peakPointOfCurve, to) + return bez + .getLUT() + .reduce( + (acc: string, p: Point, i: number) => + i === 0 ? (acc += `M ${p.x} ${p.y}`) : (acc += `L ${p.x} ${p.y}`), + "", + ) + } +} + +function getControlPoint(from: Point, to: Point, controlPoints: Point[]) { + if (controlPoints.length > 0) return controlPoints[0] + // fallback if no control points: midpoint + return { x: (to.x + from.x) * 0.5, y: (to.y + from.y) * 0.5 } +} + +function bezierCurveFromPoints(from: Point, middle: Point, to: Point): any { + return Bezier.quadraticFromPoints(from, middle, to) +} diff --git a/frontend/src/board/CursorsView.tsx b/frontend/src/board/CursorsView.tsx new file mode 100644 index 0000000..d36ea6d --- /dev/null +++ b/frontend/src/board/CursorsView.tsx @@ -0,0 +1,80 @@ +import { componentScope, h, ListView } from "harmaja" +import * as L from "lonna" +import { UserCursorPosition, UserSessionInfo } from "../../../common/src/domain" +import { CursorsStore } from "../store/cursors-store" +import { BoardZoom } from "./board-scroll-and-zoom" +import { Rect } from "../../../common/src/geometry" +import _ from "lodash" + +export const CursorsView = ({ + sessions, + cursors, + viewRect, +}: { + cursors: CursorsStore + sessions: L.Property + viewRect: L.Property +}) => { + const transitionFromCursorDelay = cursors.cursorDelay.pipe( + L.changes, + L.throttle(2000, componentScope()), + L.map((d) => { + const t = (Math.min(d, 1000) / 1000).toFixed(1) + return `all ${t}s, top ${t}s` + }), + ) + const transitionFromZoom = viewRect.pipe( + L.changes, + L.map(() => "none"), + ) + const transition = L.merge(transitionFromCursorDelay, transitionFromZoom).pipe( + L.toProperty("none", componentScope()), + ) + + const scope = componentScope() + + return ( + + observable={cursors.cursors} + renderObservable={(sessionId: string, pos_: L.Property) => { + const pos = pos_.pipe(L.skipDuplicates(_.isEqual), L.applyScope(scope)) + const changes = pos.pipe(L.changes) + const stale = L.merge( + changes.pipe( + L.debounce(1000), + L.map(() => true), + ), + changes.pipe(L.map(() => false)), + ).pipe(L.toProperty(false, scope)) + const className = L.view(stale, (s) => (s ? "cursor stale" : "cursor")) + const style = L.view(pos, transition, viewRect, (p, t, vr) => { + const x = _.clamp(p.x, vr.x, vr.x + vr.width - 1) + const y = _.clamp(p.y, vr.y, vr.y + vr.height - 1) + return { + transition: t, + left: x + "em", + top: y + "em", + } + }) + const userInfo = L.view(sessions, (sessions) => { + const session = sessions.find((s) => s.sessionId === sessionId) + return { + name: session ? session.nickname : null, + picture: session && session.userType === "authenticated" ? : null, + } + }) + + return ( + + + + {L.view(userInfo, "picture")} + {L.view(userInfo, "name")} + + + ) + }} + getKey={(c: UserCursorPosition) => c.sessionId} + /> + ) +} diff --git a/frontend/src/board/DragBorder.tsx b/frontend/src/board/DragBorder.tsx new file mode 100644 index 0000000..20279ab --- /dev/null +++ b/frontend/src/board/DragBorder.tsx @@ -0,0 +1,44 @@ +import { h, Fragment } from "harmaja" +import * as L from "lonna" +import { Board, Connection } from "../../../common/src/domain" +import { BoardCoordinateHelper } from "./board-coordinates" +import { BoardFocus } from "./board-focus" +import { Dispatch } from "../store/board-store" +import { itemDragToMove } from "./item-dragmove" +import { Tool, ToolController } from "./tool-selection" + +type Position = "left" | "right" | "top" | "bottom" + +export const DragBorder = ({ + id, + board, + coordinateHelper, + latestConnection, + focus, + toolController, + dispatch, +}: { + id: string + coordinateHelper: BoardCoordinateHelper + latestConnection: L.Property + focus: L.Atom + board: L.Property + toolController: ToolController + dispatch: Dispatch +}) => { + return ( + <> + + + + + + ) + + function DragHandle({ position }: { position: Position }) { + const ref = (e: HTMLElement) => + itemDragToMove(id, board, focus, toolController, coordinateHelper, latestConnection, dispatch, false)(e) + + return + } +} diff --git a/frontend/src/board/ImageView.tsx b/frontend/src/board/ImageView.tsx new file mode 100644 index 0000000..bf4c350 --- /dev/null +++ b/frontend/src/board/ImageView.tsx @@ -0,0 +1,82 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { Board, Connection, Image } from "../../../common/src/domain" +import { AssetStore } from "../store/asset-store" +import { Dispatch } from "../store/board-store" +import { BoardCoordinateHelper } from "./board-coordinates" +import { BoardFocus } from "./board-focus" +import { itemDragToMove } from "./item-dragmove" +import { itemSelectionHandler } from "./item-selection" +import { ToolController } from "./tool-selection" +import { itemZIndex } from "./zIndices" + +export const ImageView = ({ + id, + image, + assets, + board, + isLocked, + focus, + toolController, + coordinateHelper, + latestConnection, + dispatch, +}: { + board: L.Property + id: string + image: L.Property + isLocked: L.Property + focus: L.Atom + toolController: ToolController + coordinateHelper: BoardCoordinateHelper + latestConnection: L.Property + dispatch: Dispatch + assets: AssetStore +}) => { + const { selected, onClick, onTouchStart } = itemSelectionHandler( + id, + "image", + focus, + toolController, + board, + coordinateHelper, + latestConnection, + dispatch, + ) + const tool = toolController.tool + return ( + + ({ + top: 0, + left: 0, + transform: `translate(${p.x}em, ${p.y}em)`, + height: p.height + "em", + width: p.width + "em", + zIndex: itemZIndex(p), + position: "absolute", + } as any), + )} + > + assets.getAsset(i.assetId, i.src))} /> + {L.view(isLocked, (l) => l && 🔒)} + + ) +} diff --git a/frontend/src/board/ItemView.tsx b/frontend/src/board/ItemView.tsx new file mode 100644 index 0000000..b3a8397 --- /dev/null +++ b/frontend/src/board/ItemView.tsx @@ -0,0 +1,221 @@ +import { componentScope, h } from "harmaja" +import * as L from "lonna" +import { + AccessLevel, + Board, + Connection, + getAlign, + getHorizontalAlign, + getItemBackground, + getItemShape, + getVerticalAlign, + isContainer, + isLocalUIEvent, + isTextItem, + Item, + ItemType, + TextItem, +} from "../../../common/src/domain" +import { BoardStore, Dispatch } from "../store/board-store" +import { BoardCoordinateHelper } from "./board-coordinates" +import { BoardFocus } from "./board-focus" +import { CollaborativeTextView } from "./CollaborativeTextView" +import { DragBorder } from "./DragBorder" +import { itemDragToMove } from "./item-dragmove" +import { itemSelectionHandler } from "./item-selection" +import { TextView } from "./TextView" +import { ToolController } from "./tool-selection" +import { itemZIndex } from "./zIndices" +import { VisibilityOffIcon } from "../components/Icons" + +export const ItemView = ({ + board, + accessLevel, + id, + type, + item, + isLocked, + focus, + coordinateHelper, + latestConnection, + dispatch, + toolController, + boardStore, +}: { + board: L.Property + accessLevel: L.Property + id: string + type: ItemType + item: L.Property + isLocked: L.Property + focus: L.Atom + coordinateHelper: BoardCoordinateHelper + latestConnection: L.Property + dispatch: Dispatch + toolController: ToolController + boardStore: BoardStore +}) => { + const element = L.atom(null) + + const ref = (el: HTMLElement) => { + itemDragToMove( + id, + board, + focus, + toolController, + coordinateHelper, + latestConnection, + dispatch, + type === "container", + )(el) + element.set(el) + } + + const { itemFocus, selected, onClick, onTouchStart } = itemSelectionHandler( + id, + type, + focus, + toolController, + board, + coordinateHelper, + latestConnection, + dispatch, + ) + + function itemPadding(i: Item) { + if (i.type != "note") return undefined + + const shape = getItemShape(i) + return shape == "diamond" + ? `${i.width / 4}em` + : shape == "round" + ? `${i.width / 8}em` + : shape == "square" || shape == "rect" + ? `${(i.fontSize || 1) / 3}em` + : undefined + } + const shape = L.view(item, getItemShape) + const itemNow = item.get() + + return ( + (l ? "Item is selected by another user" : ""))} + ref={ref} + data-itemid={id} + draggable={L.view(itemFocus, (f) => f !== "editing")} + onClick={onClick} + onTouchStart={onTouchStart} + className={L.view( + selected, + L.view(item, getItemBackground), + isLocked, + (s, b, l) => + `${type} ${"color-" + b.replace("#", "").toLowerCase()} ${s ? "selected" : ""} ${ + l ? "locked" : "" + }`, + )} + style={L.view(item, (i) => { + return { + top: 0, + left: 0, + height: i.height + "em", + width: i.width + "em", + transform: `translate(${i.x}em, ${i.y}em)`, + zIndex: itemZIndex(i), + position: "absolute", + padding: itemPadding(i), + justifyContent: getJustifyContent(i), + alignItems: getAlignItems(i), + textAlign: getTextAlign(i), + } + })} + > + "shape " + s)} + style={L.view(item, (i) => { + return { + background: getItemBackground(i), + } + })} + /> + + {isTextItem(itemNow) && itemNow.crdt ? ( + } + board={board} + id={id} + accessLevel={accessLevel} + focus={focus} + itemFocus={itemFocus} + crdtStore={boardStore.crdtStore} + isLocked={isLocked} + uiEvents={boardStore.events.pipe(L.filter(isLocalUIEvent), L.applyScope(componentScope()))} + /> + ) : ( + } + dispatch={dispatch} + board={board} + toolController={toolController} + accessLevel={accessLevel} + focus={focus} + itemFocus={itemFocus} + coordinateHelper={coordinateHelper} + element={element} + /> + )} + + {L.view( + item, + (i) => isContainer(i) && i.contentsHidden, + (hidden) => + (hidden && ( +
+ +
+ )) ?? + null, + )} + + {type === "container" && ( + + )} +
+ ) +} + +export function getJustifyContent(item: Item) { + if (isTextItem(item)) { + switch (getHorizontalAlign(getAlign(item))) { + case "left": + return "flex-start" + case "center": + return "center" + case "right": + return "flex-end" + } + } + return null +} + +export function getAlignItems(item: Item) { + if (isTextItem(item)) { + switch (getVerticalAlign(getAlign(item))) { + case "top": + return "flex-start" + case "middle": + return "center" + case "bottom": + return "flex-end" + } + } + return null +} + +function getTextAlign(item: Item) { + if (isTextItem(item)) { + return getHorizontalAlign(getAlign(item)) + } + return null +} diff --git a/frontend/src/board/RectangularDragSelection.tsx b/frontend/src/board/RectangularDragSelection.tsx new file mode 100644 index 0000000..da355e7 --- /dev/null +++ b/frontend/src/board/RectangularDragSelection.tsx @@ -0,0 +1,21 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { Rect } from "../../../common/src/geometry" + +export const RectangularDragSelection = ({ rect }: { rect: L.Property }) => { + return L.view( + rect, + (r) => + r && ( + + ), + ) +} diff --git a/frontend/src/board/SaveAsTemplate.tsx b/frontend/src/board/SaveAsTemplate.tsx new file mode 100644 index 0000000..1dddbf5 --- /dev/null +++ b/frontend/src/board/SaveAsTemplate.tsx @@ -0,0 +1,32 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { Board } from "../../../common/src/domain" + +export const SaveAsTemplate = ({ board }: { board: L.Property }) => { + const currSavedBoard = L.atom(null) + + function handleLocalTemplateSave() { + const b = board.get() + if (!b) return + const saved = localStorage.getItem("rboard_templates") + const templates = saved ? (JSON.parse(saved) as Record) : {} + + templates[b.name] = b + localStorage.setItem("rboard_templates", JSON.stringify(templates)) + currSavedBoard.set(b) + } + + const changed = L.combineTemplate({ + curr: currSavedBoard, + next: board, + }).pipe(L.map((c) => c.curr !== c.next)) + return ( +
  • (c ? "" : "disabled"))} + data-test="palette-save-as-template" + onClick={() => handleLocalTemplateSave()} + > + Save as template +
  • + ) +} diff --git a/frontend/src/board/SelectionBorder.tsx b/frontend/src/board/SelectionBorder.tsx new file mode 100644 index 0000000..883dc3e --- /dev/null +++ b/frontend/src/board/SelectionBorder.tsx @@ -0,0 +1,118 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { BoardCoordinateHelper } from "./board-coordinates" +import { Board, Container } from "../../../common/src/domain" +import { BoardFocus } from "./board-focus" +import { onBoardItemDrag } from "./item-drag" +import { Dispatch } from "../store/board-store" +import { canMove } from "./board-permissions" + +type Horizontal = "left" | "right" +type Vertical = "top" | "bottom" +const borderOffset = 0.25 + +export const SelectionBorder = ({ + id, + board, + coordinateHelper, + focus, + dispatch, +}: { + id: string + coordinateHelper: BoardCoordinateHelper + focus: L.Atom + board: L.Property + dispatch: Dispatch +}) => { + const item = L.view(board, (b) => b.items[id]) + const style = L.view(item, (i) => { + return { + top: -borderOffset + "rem", + left: -borderOffset + "rem", + height: `calc(${i.height}em + 2 * ${borderOffset}rem)`, + width: `calc(${i.width}em + 2 * ${borderOffset}rem)`, + transform: `translate(${i.x}em, ${i.y}em)`, + } + }) + + return L.view( + item, + (i) => !i.hidden && canMove(i), + (m) => + m ? ( + + + + + + + + ) : null, + ) + + function DragCorner({ vertical, horizontal }: { vertical: Vertical; horizontal: Horizontal }) { + const ref = (e: HTMLElement) => + onBoardItemDrag( + e, + id, + board, + focus, + coordinateHelper, + false, + (b, startPos, items, connections, xDiff, yDiff) => { + const updatedItems = items.map(({ current, dragStartPosition }) => { + const maintainAspectRatio = + current.type === "image" || (current.type === "note" && current.shape !== "rect") + if (maintainAspectRatio) { + let minDiff = Math.min(Math.abs(xDiff), Math.abs(yDiff)) + if (minDiff < 0.1) { + xDiff = 0 + yDiff = 0 + } else { + const aspectRatio = dragStartPosition.width / dragStartPosition.height + const invert = + (horizontal == "left" && vertical == "bottom") || + (horizontal == "right" && vertical == "top") + const factor = invert ? -1 : 1 + + if (Math.abs(xDiff) == minDiff) { + // x is the smaller adjustment, use that as basis + yDiff = (minDiff / aspectRatio) * factor * sign(xDiff) + } else { + xDiff = minDiff * aspectRatio * factor * sign(yDiff) + } + } + } + + const x = horizontal === "left" ? dragStartPosition.x + xDiff : dragStartPosition.x + const y = vertical === "top" ? dragStartPosition.y + yDiff : dragStartPosition.y + const width = Math.max( + 0.5, + horizontal === "left" ? dragStartPosition.width - xDiff : dragStartPosition.width + xDiff, + ) + + const height = Math.max( + 0.5, + vertical === "top" ? dragStartPosition.height - yDiff : dragStartPosition.height + yDiff, + ) + const updatedItem = { + id: current.id, + x, + y, + width, + height, + } + return updatedItem + }) + + dispatch({ action: "item.update", boardId: b.id, items: updatedItems }) + + function sign(x: number) { + return x / Math.abs(x) + } + }, + ) + + return + } +} diff --git a/frontend/src/board/TextView.tsx b/frontend/src/board/TextView.tsx new file mode 100644 index 0000000..f173348 --- /dev/null +++ b/frontend/src/board/TextView.tsx @@ -0,0 +1,81 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { AccessLevel, Board, canWrite, getItemBackground, TextItem } from "../../../common/src/domain" +import { emptySet } from "../../../common/src/sets" +import { HTMLEditableSpan } from "../components/HTMLEditableSpan" +import { Dispatch } from "../store/board-store" +import { autoFontSize } from "./autoFontSize" +import { BoardCoordinateHelper } from "./board-coordinates" +import { BoardFocus, getSelectedItemIds } from "./board-focus" +import { contrastingColor } from "./contrasting-color" +import { ToolController } from "./tool-selection" + +interface TextViewProps { + id: string + item: L.Property + dispatch: Dispatch + board: L.Property + toolController: ToolController + accessLevel: L.Property + focus: L.Atom + itemFocus: L.Property<"none" | "selected" | "dragging" | "editing"> + coordinateHelper: BoardCoordinateHelper + element: L.Property +} + +export function TextView({ + id, + item, + dispatch, + board, + toolController, + focus, + coordinateHelper, + itemFocus, + accessLevel, + element, +}: TextViewProps) { + const textAtom = L.atom(L.view(item, "text"), (text) => + dispatch({ action: "item.update", boardId: board.get().id, items: [{ id, text }] }), + ) + const showCoords = false + const focused = L.view(focus, (f) => getSelectedItemIds(f).has(id)) + + const setEditing = (e: boolean) => { + if (toolController.tool.get() === "connect") return // Don't switch to editing in middle of connecting + dispatch({ action: "item.front", boardId: board.get().id, itemIds: [id] }) + focus.set( + e + ? { status: "editing", itemId: id } + : { status: "selected", itemIds: new Set([id]), connectionIds: emptySet() }, + ) + } + const color = L.view(item, getItemBackground, contrastingColor) + const fontSize = autoFontSize( + item, + L.view(item, (i) => (i.fontSize ? i.fontSize : 1)), + L.view(item, "text"), + focused, + coordinateHelper, + element, + ) + return ( + e.stopPropagation()} + style={L.combineTemplate({ fontSize, color })} + > + f === "editing"), + setEditing, + ), + editable: L.view(accessLevel, canWrite), + }} + /> + {showCoords && {L.view(item, (p) => Math.floor(p.x) + ", " + Math.floor(p.y))}} + + ) +} diff --git a/frontend/src/board/VideoView.tsx b/frontend/src/board/VideoView.tsx new file mode 100644 index 0000000..dfb0966 --- /dev/null +++ b/frontend/src/board/VideoView.tsx @@ -0,0 +1,87 @@ +import { h } from "harmaja" +import * as L from "lonna" +import { BoardCoordinateHelper } from "./board-coordinates" +import { Board, Connection, Image, Video } from "../../../common/src/domain" +import { BoardFocus } from "./board-focus" +import { AssetStore } from "../store/asset-store" +import { itemDragToMove } from "./item-dragmove" +import { itemSelectionHandler } from "./item-selection" +import { Dispatch } from "../store/board-store" +import { Tool, ToolController } from "./tool-selection" +import { DragBorder } from "./DragBorder" +import { itemZIndex } from "./zIndices" + +export const VideoView = ({ + id, + video, + assets, + board, + isLocked, + focus, + toolController, + coordinateHelper, + latestConnection, + dispatch, +}: { + board: L.Property + id: string + video: L.Property