snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
Some checks failed
build / test (18.x) (push) Failing after 10s
build / lint (18.x) (push) Failing after 0s
build / docker-image (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 16:16:17 +05:00
commit d57328655f
233 changed files with 21282 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -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

11
.editorconfig Normal file
View File

@@ -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

24
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@@ -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.

View File

@@ -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.

83
.github/workflows/build.yml vendored Normal file
View File

@@ -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}}

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
.cache/
node_modules/
dist/
yarn-error.log
.env
*.mp4
backend/localfiles/
/ignore
.DS_Store
latest.dump
.vscode
backups
playwright/results

5
.huskyrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
hooks: {
"pre-commit": "lint-staged",
},
}

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
package.json
.cache/
node_modules/
dist/
yarn-error.log
.env
*.mp4
backend/localfiles/
ignore/

5
.prettierrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
printWidth: 120,
semi: false,
trailingComma: "all",
}

31
Dockerfile Normal file
View File

@@ -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" ]

8
LICENSE.txt Normal file
View File

@@ -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.

437
README.md Normal file
View File

@@ -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_<boardid>"]`
2. Copy that string to clipboard
3. Run the following in your localhost site console:
localStorage["board_32de1a50-09a6-4453-9b9e-ed10c56afa99"]=JSON.stringify(
<paste content here>
)
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://<yourdomain>/. 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 `<OURBOARD_ROOT_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 `<OURBOARD_ROOT_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)

View File

@@ -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();
`)
}

View File

@@ -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;
`)
}

View File

@@ -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

View File

@@ -0,0 +1,4 @@
update board
set public_read = 't',
public_write = 't'
where content -> 'accessPolicy' is null

View File

@@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

View File

@@ -0,0 +1 @@
ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email);

View File

@@ -0,0 +1 @@
alter table board_event add column crdt_update bytea null;

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
ALTER TABLE board_event DROP CONSTRAINT board_event_pkey;
CREATE INDEX board_event_board_index ON board_event (board_id);

View File

@@ -0,0 +1 @@
alter table board drop column history;

74
backend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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,
)

View File

@@ -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] })
})

View File

@@ -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` })
}

View File

@@ -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 })
}),
)

View File

@@ -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 }
}

View File

@@ -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<void>) {
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()
}
})
}

View File

@@ -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 })
}),
)

View File

@@ -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 = `<a href=${url}>`
const linkHTML = `${linkStart}${htmlEncode(number)}</a> ${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()
}
}
})

View File

@@ -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`)
}
}

View File

@@ -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)
}),
)

124
backend/src/api/utils.ts Normal file
View File

@@ -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<T>(
request: { routeParams: { boardId: string }; headers: { API_TOKEN?: string | undefined } },
fn: (board: ServerSideBoardState) => Promise<T>,
) {
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)
}
}

View File

@@ -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<MessageHandlerResult> => {
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
}

View File

@@ -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<number, number>()
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])
})
})

205
backend/src/board-state.ts Normal file
View File

@@ -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<typeof Locks>
cursorsMoved: boolean
cursorPositions: BoardCursorPositions
accessTokens: string[]
sessions: UserSession[]
}
export type ServerSideBoardStateInternal =
| ServerSideBoardState
| {
ready: false
fetch: Promise<ServerSideBoardState | null>
}
let boards: Map<Id, ServerSideBoardStateInternal> = new Map()
export async function getBoard(id: Id): Promise<ServerSideBoardState | null> {
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<ServerSideBoardState> {
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<void> = 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)

481
backend/src/board-store.ts Normal file
View File

@@ -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<BoardInfo | null> {
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<BoardAndAccessTokens | null> {
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<void> {
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<void> {
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<string> {
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<void> {
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<BoardHistoryBundle[]> {
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<Uint8Array[]> {
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<BoardHistoryBundleMeta[]> {
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<Id[]> {
const result = await client.query("SELECT id FROM board")
return result.rows.map((row) => row.id)
}

View File

@@ -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<string, WebSocket[]> = {}
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()
}
})
}

View File

@@ -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<MessageHandlerResult> {
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
}

View File

@@ -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<T>(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<number> {
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
}
}

39
backend/src/config.ts Normal file
View File

@@ -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<typeof CrdtConfigString>
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,
}
}

View File

@@ -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<Id, Serial> = {}
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<MessageHandlerResult>
export type MessageHandlerResult = { boardId: Id; serial: Serial } | boolean

72
backend/src/db.ts Normal file
View File

@@ -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<T>(f: (client: PoolClient) => Promise<T>): Promise<T> {
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<T>(f: (client: PoolClient) => Promise<T>): Promise<T> {
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()
}
}

View File

@@ -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<T>(codec: t.Type<T, any>, 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<t.Errors>) {
super(report_(errors.left))
}
}
function report_(errors: t.Errors) {
return PathReporter.report(left(errors)).join("\n")
}

7
backend/src/env.ts Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,53 @@
export function AutoExpiringMap<V extends any>(ttlSeconds: number) {
const timers = new Map<string | number, NodeJS.Timeout | undefined>()
const data: Record<string, V> = {}
const listeners: ((v: Record<string, V>) => 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<string, V>) => any) => {
listeners.push(fn)
return autoExpiringMap
},
}
return autoExpiringMap
}

View File

@@ -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()
})
}

View File

@@ -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<OAuthAuthenticatedUser> {
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<string> {
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),
}),
])

View File

@@ -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
}
}

View File

@@ -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<OAuthAuthenticatedUser> {
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,
}
}

View File

@@ -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")

View File

@@ -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 })
}

58
backend/src/locker.ts Normal file
View File

@@ -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<string>(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)
}
}
})
}

60
backend/src/oauth.ts Normal file
View File

@@ -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<string>
getAccountFromCode: (code: string) => Promise<OAuthAuthenticatedUser>
logout?: (req: Request, res: Response) => Promise<void>
}
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...<script>document.location='${authUrl}'</script>`)
})
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

View File

@@ -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)]
}

View File

@@ -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()
}
})
}
}

26
backend/src/s3.ts Normal file
View File

@@ -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
}

60
backend/src/server.ts Normal file
View File

@@ -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)
})

9
backend/src/storage.ts Normal file
View File

@@ -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

View File

@@ -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)
}
})()

62
backend/src/user-store.ts Normal file
View File

@@ -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<string> {
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<RecentBoard[]> {
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(),
}
})
}

View File

@@ -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<void>()
const closeE = L.bus<void>()
const msgE = L.bus<object>()
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<typeof WsWrapper>
type WsUserData = {
handler: MessageHandler
}
export function startUWebSocketsServer(port: number) {
const config = getConfig()
const app = uws.App()
const sockets = new Map<uws.WebSocket, WsWrapper>()
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)
})
}

View File

@@ -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<SocketId, UserSession> = {}
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<void> {
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<EventUserInfoAuthenticated> {
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<Id, UserCursorPosition>) {
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<Id, NodeJS.Timeout | undefined> = {}
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
}

48
backend/src/ws-wrapper.ts Normal file
View File

@@ -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<typeof WsWrapper>
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
}

View File

@@ -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<void>
}
export class Docs {
readonly docs = new Map<string, DocState>()
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<WSSharedDoc> {
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)
}
}

View File

@@ -0,0 +1,25 @@
import * as Y from "yjs"
export interface Persistence {
bindState: (docName: string, ydoc: Y.Doc) => Promise<void>
writeState: (docName: string, ydoc: Y.Doc) => Promise<any>
}
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) => {},
}
}

View File

@@ -0,0 +1,2 @@
export const messageSync = 0
export const messageAwareness = 1

View File

@@ -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<WebSocket, Set<number>> = new Map<WebSocket, Set<number>>()
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)
}
}

View File

@@ -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])
}
}

9
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"rootDir": "..",
"sourceMap": true
}
}

127
benchmark/benchmark.ts Normal file
View File

@@ -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")

View File

@@ -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<E extends AppEvent>(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)
}

37
common/src/arrays.ts Normal file
View File

@@ -0,0 +1,37 @@
import { isArray, isEqual } from "lodash"
export function toArray<T>(x: T | T[]) {
if (isArray(x)) return x
return [x]
}
export function arrayIdMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {
return arrayEquals(idsOf(a), idsOf(b))
}
export function arrayObjectKeysMatch<T extends object>(a: T[] | T, b: T[] | T) {
return arrayEquals(keysOf(a), keysOf(b))
}
export function arrayIdAndKeysMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {
return arrayIdMatch(a, b) && arrayObjectKeysMatch(a, b)
}
export function idsOf<T extends { id: string }>(a: T[] | T): string[] {
return toArray(a).map((x) => x.id)
}
export function keysOf<T extends object>(a: T[] | T): string[][] {
return toArray(a).map((x) => Object.keys(x))
}
export function arrayEquals<T>(a: T[] | T, b: T[] | T) {
return isEqual(toArray(a), toArray(b))
}
export function arrayToRecordById<T extends { id: string }>(arr: T[], init: Record<string, T> = {}): Record<string, T> {
return arr.reduce((acc: Record<string, T>, elem: T) => {
acc[elem.id] = elem
return acc
}, init)
}

View File

@@ -0,0 +1,4 @@
export function assertNotNull<T>(x: T | null | undefined): T {
if (x === null || x === undefined) throw Error("Assertion failed: " + x)
return x
}

View File

@@ -0,0 +1,6 @@
export type OAuthAuthenticatedUser = {
name: string
email: string
picture?: string
domain: string | null
}

View File

@@ -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 ")
}
}
}
}

View File

@@ -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)`)

708
common/src/board-reducer.ts Normal file
View File

@@ -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<string, Item>, 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<T extends { id: Id }>(update: Update<T>, original: T): Update<T> {
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<Id, Item> {
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<T>(
items: Record<string, T>,
modification: (items: Record<string, T>) => void,
inplace: boolean,
): Record<string, T> {
const updated = inplace ? items : { ...items }
modification(updated)
return updated
}
function applyListModification<T>(list: T[], modification: (list: T[]) => void, inplace: boolean) {
const newList = inplace ? list : [...list]
modification(newList)
return newList
}
function replaceById<T extends { id: Id }>(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<string, Item>, 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<Record<AnyItemKey, BoardPermission>> = {
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<Record<keyof Connection, BoardPermission>> = {
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<Id, ItemMove> = {}
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<Id, ConnectionMove> = {}
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<Id, Move>,
items: Record<string, Item>,
): 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
}

26
common/src/colors.ts Normal file
View File

@@ -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 },
]

View File

@@ -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<string, Item>): Point | Item {
if (isItemEndPoint(e)) {
return resolveItemEndpoint(e, b)
}
return e
}
export function resolveItemEndpoint(e: ConnectionEndPointToItem, b: Board | Record<string, Item>): 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<string, Item>) => (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<string, Item> | Board) {
const start = resolveEndpoint(connection.from, context)
const end = resolveEndpoint(connection.to, context)
return !isItem(start) && !isItem(end) && containedBy(start, item) && containedBy(end, item)
}

756
common/src/domain.ts Normal file
View File

@@ -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<T extends t.Type<any>>(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<Id, Item>
connections: Connection[]
}
export type Board = BoardAttributes &
BoardContents & {
serial: Serial
}
export type BoardStub = Pick<Board, "id" | "name" | "accessPolicy" | "crdt"> & { 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<typeof AccessLevelCodec>
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<typeof AccessListEntryCodec>
export const BoardAccessPolicyDefinedCodec = t.type({
allowList: t.array(AccessListEntryCodec),
publicRead: optional(t.boolean),
publicWrite: optional(t.boolean),
})
export type BoardAccessPolicyDefined = t.TypeOf<typeof BoardAccessPolicyDefinedCodec>
export const BoardAccessPolicyCodec = t.union([t.undefined, BoardAccessPolicyDefinedCodec])
export type BoardAccessPolicy = t.TypeOf<typeof BoardAccessPolicyCodec>
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<Id, UserCursorPosition>
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<Connection, "from" | "to"> & {
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<Id, Id>
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<T> = Partial<T> & { id: Id }
export type ItemUpdate<I extends Item = Item> = Update<I>
export type ConnectionUpdate = Update<Connection>
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<Id, Serial> }
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<Id, UserCursorPosition> }
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<Namespace extends string, T = AppEvent> = T extends { action: `${Namespace}.${string}` }
? T
: never
export function actionNamespaceIs<Namespace extends string>(
ns: Namespace,
a: AppEvent,
): a is NamespacedEvent<Namespace> {
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<string, Item>) => (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<string, Item>) => (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<Id> {
const recursiveIds = new Set<Id>()
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<string, Item>, 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<string, Item>) =>
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<I extends TextItem>(item: I, a: HorizontalAlign): ItemUpdate<I> {
const letter = a === "left" ? "L" : a === "center" ? "C" : "R"
const align = `${getAlign(item)[0]}${letter}`
return { id: item.id, align } as ItemUpdate<I>
}
export function setVerticalAlign<I extends TextItem>(item: I, a: VerticalAlign): ItemUpdate<I> {
const letter = a === "top" ? "T" : a === "middle" ? "M" : "B"
const align = `${letter}${getAlign(item)[1]}`
return { id: item.id, align } as ItemUpdate<I>
}

71
common/src/geometry.ts Normal file
View File

@@ -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
}

View File

@@ -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([])
})
})
})

126
common/src/migration.ts Normal file
View File

@@ -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<string, Item>): 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
}

16
common/src/sets.ts Normal file
View File

@@ -0,0 +1,16 @@
export function toggleInSet<T>(item: T, set: Set<T>) {
if (set.has(item)) {
return new Set([...set].filter((i) => i !== item))
}
return new Set([...set].concat(item))
}
export function difference<A>(setA: Set<A>, setB: Set<A>) {
let _difference = new Set(setA)
for (let elem of setB) {
_difference.delete(elem)
}
return _difference
}
export const emptySet = <A>() => new Set<A>()

3
common/src/sleep.ts Normal file
View File

@@ -0,0 +1,3 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms))
}

50
common/src/vector2.ts Normal file
View File

@@ -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
}

6
cypress.json Normal file
View File

@@ -0,0 +1,6 @@
{
"pluginsFile": false,
"supportFile": false,
"fixturesFolder": false,
"retries": 2
}

33
docker-compose.yaml Normal file
View File

@@ -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

3
frontend/.sassrc Normal file
View File

@@ -0,0 +1,3 @@
{
"includePaths": ["node_modules"]
}

106
frontend/esbuild.js Normal file
View File

@@ -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)
}

16
frontend/index.tmpl.html Normal file
View File

@@ -0,0 +1,16 @@
<html lang="en">
<head>
<title>OurBoard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link href="https://fonts.googleapis.com/css2?family=Raleway:wght@500&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill@1.3.7/dist/quill.core.css" rel="stylesheet" />
<script src="https://apis.google.com/js/api.js"></script>
<script defer="defer" src="JAVASCRIPT_BUNDLE"></script>
<link href="CSS_BUNDLE" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
</body>
</html>

66
frontend/package.json Normal file
View File

@@ -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"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

10
frontend/src/app.scss Normal file
View File

@@ -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";

View File

@@ -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<Routes>,
serverEvents: L.EventStream<EventFromServer>,
) {
dispatch({ action: "board.add", payload: newBoard })
serverEvents.forEach(
(e) => e.action === "board.add.ack" && navigator.navigateByParams(BOARD_PATH, { boardId: newBoard.id }),
)
}

View File

@@ -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<UserSessionState>
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<HTMLElement | null>(null)
const scrollElement = L.atom<HTMLElement | null>(null)
const boardElement = L.atom<HTMLElement | null>(null)
const latestNoteId = L.atom<Id | null>(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<Id | null>(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<JSX.KeyboardEvent>(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<any>(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 (
<div id="root" className={className}>
<ModalContainer content={modalContent} />
<BoardViewHeader
{...{
board,
usersOnBoard: L.view(boardState, "users"),
sessionState,
dispatch,
accessLevel,
modalContent,
eventsFromServer: boardStore.eventsFromServer,
viewRect,
online: L.view(boardStore.state, (s) => s.status === "online"),
crdtStore: boardStore.crdtStore,
}}
/>
<div className="content-container" ref={containerElement.set}>
<div className="scroll-container" ref={scrollElement.set}>
<div className="border-container" style={borderContainerStyle}>
<div
className={L.view(tool, (t) => "board " + t)}
draggable={isFirefox ? L.view(focus, (f) => f.status !== "editing") : true}
ref={boardElement.set}
onClick={onClick}
onTouchEnd={onClick}
>
<ListView observable={items} renderObservable={renderItem} getKey={(i) => i.id} />
{L.view(tool, (t) =>
t === "connect" ? null : (
<ListView
observable={selectedItems}
renderObservable={renderSelectionBorder}
getKey={(i) => i.id}
/>
),
)}
<RectangularDragSelection {...{ rect: selectionRect }} />
<CursorsView {...{ cursors, sessions, viewRect }} />
<ContextMenuView
{...{
latestNote,
dispatch,
board,
focus,
viewRect,
}}
/>
<ConnectionsView {...{ board, zoom, dispatch, focus, coordinateHelper }} />
</div>
</div>
</div>
<BoardToolLayer
{...{
board,
accessLevel,
boardStore,
containerElement,
coordinateHelper,
dispatch,
focus,
latestNote,
onAdd,
toolController,
sessionState,
...zoomControls,
}}
/>
</div>
</div>
)
function renderSelectionBorder(id: string, item: L.Property<Item>) {
return <SelectionBorder {...{ id, tool, item: item, coordinateHelper, board, focus, dispatch }} />
}
function renderItem(id: string, item: L.Property<Item>) {
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 (
<ItemView
{...{
board,
history,
id,
type: t,
item: item as L.Property<Note>,
isLocked,
focus,
coordinateHelper,
latestConnection,
dispatch,
toolController,
accessLevel,
boardStore,
}}
/>
)
case "image":
return (
<ImageView
{...{
id,
image: item as L.Property<Image>,
assets,
board,
isLocked,
focus,
toolController,
coordinateHelper,
latestConnection,
dispatch,
}}
/>
)
case "video":
return (
<VideoView
{...{
id,
video: item as L.Property<Video>,
assets,
board,
isLocked,
focus,
toolController,
coordinateHelper,
latestConnection,
dispatch,
}}
/>
)
default:
throw Error("Unsupported item: " + t)
}
})
}
}

View File

@@ -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<BoardAccessStatus>
sessionState: L.Property<UserSessionState>
board: L.Property<Board>
}) => {
// 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 (
<div className="board-status-message">
<div>
<p>Board not found. A typo, maybe?</p>
</div>
</div>
)
}
if (s === "denied-permanently") {
return (
<div className="board-status-message">
<div>
<p>
Sorry, access denied. Click <a onClick={signIn}>here</a> to sign in with another account.
</p>
</div>
</div>
)
}
if (s === "login-required") {
if (sessionStatus === "login-failed") {
return (
<div className="board-status-message">
<div>
Something went wrong with logging in. Click <a onClick={signIn}>here</a> to try again.
</div>
</div>
)
}
return (
<div className="board-status-message">
<div>
This board is for authorized users only. Click <a onClick={signIn}>here</a> to sign in.
</div>
</div>
)
}
return null
})
}

Some files were not shown because too many files have changed in this diff Show More