snapshot of https://github.com/raimohanska/ourboard.git (master, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
11
.editorconfig
Normal 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
24
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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.
|
||||
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
83
.github/workflows/build.yml
vendored
Normal 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
13
.gitignore
vendored
Normal 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
5
.huskyrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
hooks: {
|
||||
"pre-commit": "lint-staged",
|
||||
},
|
||||
}
|
||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
package.json
|
||||
.cache/
|
||||
node_modules/
|
||||
dist/
|
||||
yarn-error.log
|
||||
.env
|
||||
*.mp4
|
||||
backend/localfiles/
|
||||
ignore/
|
||||
5
.prettierrc.js
Normal file
5
.prettierrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
semi: false,
|
||||
trailingComma: "all",
|
||||
}
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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
8
LICENSE.txt
Normal 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
437
README.md
Normal 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)
|
||||
16
backend/migrations/001_init.js
Normal file
16
backend/migrations/001_init.js
Normal 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();
|
||||
`)
|
||||
}
|
||||
7
backend/migrations/002_add_first_serial.js
Normal file
7
backend/migrations/002_add_first_serial.js
Normal 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;
|
||||
`)
|
||||
}
|
||||
27
backend/migrations/003_refactor_access.sql
Normal file
27
backend/migrations/003_refactor_access.sql
Normal 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
|
||||
4
backend/migrations/004_public_boards_access.sql
Normal file
4
backend/migrations/004_public_boards_access.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
update board
|
||||
set public_read = 't',
|
||||
public_write = 't'
|
||||
where content -> 'accessPolicy' is null
|
||||
1
backend/migrations/005_uuid_extension.sql
Normal file
1
backend/migrations/005_uuid_extension.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
1
backend/migrations/006_unique_email.sql
Normal file
1
backend/migrations/006_unique_email.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email);
|
||||
1
backend/migrations/007_add_crdt_update.sql
Normal file
1
backend/migrations/007_add_crdt_update.sql
Normal file
@@ -0,0 +1 @@
|
||||
alter table board_event add column crdt_update bytea null;
|
||||
2
backend/migrations/008_allow_crdt_only_bundle.sql
Normal file
2
backend/migrations/008_allow_crdt_only_bundle.sql
Normal 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);
|
||||
2
backend/migrations/009_drop_board_event_primary_key.sql
Normal file
2
backend/migrations/009_drop_board_event_primary_key.sql
Normal 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);
|
||||
1
backend/migrations/010_drop_history_column.sql
Normal file
1
backend/migrations/010_drop_history_column.sql
Normal file
@@ -0,0 +1 @@
|
||||
alter table board drop column history;
|
||||
74
backend/package.json
Normal file
74
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
backend/src/api/api-routes.ts
Normal file
22
backend/src/api/api-routes.ts
Normal 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,
|
||||
)
|
||||
29
backend/src/api/board-create.ts
Normal file
29
backend/src/api/board-create.ts
Normal 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] })
|
||||
})
|
||||
62
backend/src/api/board-csv-get.ts
Normal file
62
backend/src/api/board-csv-get.ts
Normal 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` })
|
||||
}
|
||||
22
backend/src/api/board-get.ts
Normal file
22
backend/src/api/board-get.ts
Normal 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 })
|
||||
}),
|
||||
)
|
||||
34
backend/src/api/board-hierarchy-get.ts
Normal file
34
backend/src/api/board-hierarchy-get.ts
Normal 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 }
|
||||
}
|
||||
47
backend/src/api/board-history-get.ts
Normal file
47
backend/src/api/board-history-get.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
29
backend/src/api/board-update.ts
Normal file
29
backend/src/api/board-update.ts
Normal 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 })
|
||||
}),
|
||||
)
|
||||
74
backend/src/api/github-webhook.ts
Normal file
74
backend/src/api/github-webhook.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
137
backend/src/api/item-create-or-update.ts
Normal file
137
backend/src/api/item-create-or-update.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
41
backend/src/api/item-create.ts
Normal file
41
backend/src/api/item-create.ts
Normal 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
124
backend/src/api/utils.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
180
backend/src/board-event-handler.ts
Normal file
180
backend/src/board-event-handler.ts
Normal 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
|
||||
}
|
||||
16
backend/src/board-state.test.ts
Normal file
16
backend/src/board-state.test.ts
Normal 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
205
backend/src/board-state.ts
Normal 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
481
backend/src/board-store.ts
Normal 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)
|
||||
}
|
||||
102
backend/src/board-yjs-server.ts
Normal file
102
backend/src/board-yjs-server.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
114
backend/src/common-event-handler.ts
Normal file
114
backend/src/common-event-handler.ts
Normal 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
|
||||
}
|
||||
116
backend/src/compact-history.ts
Normal file
116
backend/src/compact-history.ts
Normal 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
39
backend/src/config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
68
backend/src/connection-handler.ts
Normal file
68
backend/src/connection-handler.ts
Normal 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
72
backend/src/db.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
21
backend/src/decodeOrThrow.ts
Normal file
21
backend/src/decodeOrThrow.ts
Normal 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
7
backend/src/env.ts
Normal 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
|
||||
}
|
||||
53
backend/src/expiring-map.ts
Normal file
53
backend/src/expiring-map.ts
Normal 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
|
||||
}
|
||||
162
backend/src/express-server.ts
Normal file
162
backend/src/express-server.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
123
backend/src/generic-oidc-auth.ts
Normal file
123
backend/src/generic-oidc-auth.ts
Normal 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),
|
||||
}),
|
||||
])
|
||||
239
backend/src/github-webhook/example-payload.json
Normal file
239
backend/src/github-webhook/example-payload.json
Normal 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
|
||||
}
|
||||
}
|
||||
70
backend/src/google-auth.ts
Normal file
70
backend/src/google-auth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
6
backend/src/host-config.ts
Normal file
6
backend/src/host-config.ts
Normal 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")
|
||||
53
backend/src/http-session.ts
Normal file
53
backend/src/http-session.ts
Normal 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
58
backend/src/locker.ts
Normal 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
60
backend/src/oauth.ts
Normal 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
|
||||
72
backend/src/professions.ts
Normal file
72
backend/src/professions.ts
Normal 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)]
|
||||
}
|
||||
17
backend/src/require-auth.ts
Normal file
17
backend/src/require-auth.ts
Normal 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
26
backend/src/s3.ts
Normal 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
60
backend/src/server.ts
Normal 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
9
backend/src/storage.ts
Normal 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
|
||||
11
backend/src/tools/wait-for-db.ts
Normal file
11
backend/src/tools/wait-for-db.ts
Normal 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
62
backend/src/user-store.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
105
backend/src/uwebsockets-server.ts
Normal file
105
backend/src/uwebsockets-server.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
347
backend/src/websocket-sessions.ts
Normal file
347
backend/src/websocket-sessions.ts
Normal 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
48
backend/src/ws-wrapper.ts
Normal 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
|
||||
}
|
||||
58
backend/src/y-websocket-server/Docs.ts
Normal file
58
backend/src/y-websocket-server/Docs.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
25
backend/src/y-websocket-server/Persistence.ts
Normal file
25
backend/src/y-websocket-server/Persistence.ts
Normal 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) => {},
|
||||
}
|
||||
}
|
||||
2
backend/src/y-websocket-server/Protocol.ts
Normal file
2
backend/src/y-websocket-server/Protocol.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const messageSync = 0
|
||||
export const messageAwareness = 1
|
||||
105
backend/src/y-websocket-server/WSSharedDoc.ts
Normal file
105
backend/src/y-websocket-server/WSSharedDoc.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
131
backend/src/y-websocket-server/YWebSocketServer.ts
Normal file
131
backend/src/y-websocket-server/YWebSocketServer.ts
Normal 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
9
backend/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "..",
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
127
benchmark/benchmark.ts
Normal file
127
benchmark/benchmark.ts
Normal 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")
|
||||
87
common/src/action-folding.ts
Normal file
87
common/src/action-folding.ts
Normal 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
37
common/src/arrays.ts
Normal 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)
|
||||
}
|
||||
4
common/src/assertNotNull.ts
Normal file
4
common/src/assertNotNull.ts
Normal 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
|
||||
}
|
||||
6
common/src/authenticated-user.ts
Normal file
6
common/src/authenticated-user.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type OAuthAuthenticatedUser = {
|
||||
name: string
|
||||
email: string
|
||||
picture?: string
|
||||
domain: string | null
|
||||
}
|
||||
40
common/src/board-crdt-helper.ts
Normal file
40
common/src/board-crdt-helper.ts
Normal 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 ")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
common/src/board-reducer.benchmark.ts
Normal file
59
common/src/board-reducer.benchmark.ts
Normal 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
708
common/src/board-reducer.ts
Normal 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
26
common/src/colors.ts
Normal 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 },
|
||||
]
|
||||
193
common/src/connection-utils.ts
Normal file
193
common/src/connection-utils.ts
Normal 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
756
common/src/domain.ts
Normal 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
71
common/src/geometry.ts
Normal 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
|
||||
}
|
||||
222
common/src/migration.test.ts
Normal file
222
common/src/migration.test.ts
Normal 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
126
common/src/migration.ts
Normal 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
16
common/src/sets.ts
Normal 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
3
common/src/sleep.ts
Normal 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
50
common/src/vector2.ts
Normal 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
6
cypress.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pluginsFile": false,
|
||||
"supportFile": false,
|
||||
"fixturesFolder": false,
|
||||
"retries": 2
|
||||
}
|
||||
33
docker-compose.yaml
Normal file
33
docker-compose.yaml
Normal 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
3
frontend/.sassrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"includePaths": ["node_modules"]
|
||||
}
|
||||
106
frontend/esbuild.js
Normal file
106
frontend/esbuild.js
Normal 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
16
frontend/index.tmpl.html
Normal 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
66
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/img/reaktor-logo.jpg
Normal file
BIN
frontend/public/img/reaktor-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
10
frontend/src/app.scss
Normal file
10
frontend/src/app.scss
Normal 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";
|
||||
46
frontend/src/board-navigation.ts
Normal file
46
frontend/src/board-navigation.ts
Normal 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 }),
|
||||
)
|
||||
}
|
||||
434
frontend/src/board/BoardView.tsx
Normal file
434
frontend/src/board/BoardView.tsx
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
60
frontend/src/board/BoardViewMessage.tsx
Normal file
60
frontend/src/board/BoardViewMessage.tsx
Normal 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
Reference in New Issue
Block a user