Showing preview only (1,098K chars total). Download the full file or copy to clipboard to get everything.
Repository: raimohanska/ourboard
Branch: master
Commit: b0a194724d94
Files: 230
Total size: 1.0 MB
Directory structure:
gitextract_96m72lw6/
├── .dockerignore
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── build.yml
├── .gitignore
├── .huskyrc.js
├── .nvmrc
├── .prettierignore
├── .prettierrc.js
├── Dockerfile
├── LICENSE.txt
├── README.md
├── backend/
│ ├── migrations/
│ │ ├── 001_init.js
│ │ ├── 002_add_first_serial.js
│ │ ├── 003_refactor_access.sql
│ │ ├── 004_public_boards_access.sql
│ │ ├── 005_uuid_extension.sql
│ │ ├── 006_unique_email.sql
│ │ ├── 007_add_crdt_update.sql
│ │ ├── 008_allow_crdt_only_bundle.sql
│ │ ├── 009_drop_board_event_primary_key.sql
│ │ └── 010_drop_history_column.sql
│ ├── package.json
│ ├── src/
│ │ ├── api/
│ │ │ ├── api-routes.ts
│ │ │ ├── board-create.ts
│ │ │ ├── board-csv-get.ts
│ │ │ ├── board-get.ts
│ │ │ ├── board-hierarchy-get.ts
│ │ │ ├── board-history-get.ts
│ │ │ ├── board-update.ts
│ │ │ ├── github-webhook.ts
│ │ │ ├── item-create-or-update.ts
│ │ │ ├── item-create.ts
│ │ │ └── utils.ts
│ │ ├── board-event-handler.ts
│ │ ├── board-state.test.ts
│ │ ├── board-state.ts
│ │ ├── board-store.ts
│ │ ├── board-yjs-server.ts
│ │ ├── common-event-handler.ts
│ │ ├── compact-history.ts
│ │ ├── config.ts
│ │ ├── connection-handler.ts
│ │ ├── db.ts
│ │ ├── decodeOrThrow.ts
│ │ ├── env.ts
│ │ ├── expiring-map.ts
│ │ ├── express-server.ts
│ │ ├── generic-oidc-auth.ts
│ │ ├── github-webhook/
│ │ │ └── example-payload.json
│ │ ├── google-auth.ts
│ │ ├── host-config.ts
│ │ ├── http-session.ts
│ │ ├── locker.ts
│ │ ├── oauth.ts
│ │ ├── professions.ts
│ │ ├── require-auth.ts
│ │ ├── s3.ts
│ │ ├── server.ts
│ │ ├── storage.ts
│ │ ├── tools/
│ │ │ └── wait-for-db.ts
│ │ ├── user-store.ts
│ │ ├── uwebsockets-server.ts
│ │ ├── websocket-sessions.ts
│ │ ├── ws-wrapper.ts
│ │ └── y-websocket-server/
│ │ ├── Docs.ts
│ │ ├── Persistence.ts
│ │ ├── Protocol.ts
│ │ ├── WSSharedDoc.ts
│ │ └── YWebSocketServer.ts
│ └── tsconfig.json
├── benchmark/
│ └── benchmark.ts
├── common/
│ └── src/
│ ├── action-folding.ts
│ ├── arrays.ts
│ ├── assertNotNull.ts
│ ├── authenticated-user.ts
│ ├── board-crdt-helper.ts
│ ├── board-reducer.benchmark.ts
│ ├── board-reducer.ts
│ ├── colors.ts
│ ├── connection-utils.ts
│ ├── domain.ts
│ ├── geometry.ts
│ ├── migration.test.ts
│ ├── migration.ts
│ ├── sets.ts
│ ├── sleep.ts
│ └── vector2.ts
├── cypress.json
├── docker-compose.yaml
├── frontend/
│ ├── .sassrc
│ ├── esbuild.js
│ ├── index.tmpl.html
│ ├── package.json
│ ├── src/
│ │ ├── app.scss
│ │ ├── board/
│ │ │ ├── BoardView.tsx
│ │ │ ├── BoardViewMessage.tsx
│ │ │ ├── CollaborativeTextView.tsx
│ │ │ ├── ConnectionsView.tsx
│ │ │ ├── CursorsView.tsx
│ │ │ ├── DragBorder.tsx
│ │ │ ├── ImageView.tsx
│ │ │ ├── ItemView.tsx
│ │ │ ├── RectangularDragSelection.tsx
│ │ │ ├── SaveAsTemplate.tsx
│ │ │ ├── SelectionBorder.tsx
│ │ │ ├── TextView.tsx
│ │ │ ├── VideoView.tsx
│ │ │ ├── autoFontSize.ts
│ │ │ ├── board-coordinates.ts
│ │ │ ├── board-drag.ts
│ │ │ ├── board-focus.ts
│ │ │ ├── board-permissions.ts
│ │ │ ├── board-scroll-and-zoom.ts
│ │ │ ├── boardContentArea.ts
│ │ │ ├── contextmenu/
│ │ │ │ ├── ContextMenuView.tsx
│ │ │ │ ├── alignments.tsx
│ │ │ │ ├── areaTiling.tsx
│ │ │ │ ├── colors.tsx
│ │ │ │ ├── colorsAndShapes.tsx
│ │ │ │ ├── connection-ends.tsx
│ │ │ │ ├── fontSizes.tsx
│ │ │ │ ├── hideContents.tsx
│ │ │ │ ├── lock.tsx
│ │ │ │ ├── shapes.tsx
│ │ │ │ ├── textAlignments.tsx
│ │ │ │ └── textFormats.tsx
│ │ │ ├── contrasting-color.ts
│ │ │ ├── double-click.ts
│ │ │ ├── header/
│ │ │ │ ├── BoardViewHeader.tsx
│ │ │ │ ├── OtherUsersView.tsx
│ │ │ │ ├── SharingModalDialog.tsx
│ │ │ │ ├── UserInfoModal.tsx
│ │ │ │ └── UserInfoView.tsx
│ │ │ ├── image-upload.ts
│ │ │ ├── item-connect.ts
│ │ │ ├── item-create.ts
│ │ │ ├── item-cut-copy-paste.ts
│ │ │ ├── item-delete.ts
│ │ │ ├── item-drag.ts
│ │ │ ├── item-dragmove.ts
│ │ │ ├── item-duplicate.ts
│ │ │ ├── item-hide-contents.ts
│ │ │ ├── item-move-with-arrow-keys.ts
│ │ │ ├── item-organizer.test.ts
│ │ │ ├── item-organizer.ts
│ │ │ ├── item-packer.ts
│ │ │ ├── item-select-all.ts
│ │ │ ├── item-selection.ts
│ │ │ ├── item-setcontainer.ts
│ │ │ ├── item-undo-redo.ts
│ │ │ ├── keyboard-shortcuts.ts
│ │ │ ├── local-storage-atom.ts
│ │ │ ├── quillClickableLink.ts
│ │ │ ├── quillPasteLinkOverText.ts
│ │ │ ├── synchronize-focus-with-server.ts
│ │ │ ├── tool-selection.ts
│ │ │ ├── toolbars/
│ │ │ │ ├── BackToAllBoardsLink.tsx
│ │ │ │ ├── BoardToolLayer.tsx
│ │ │ │ ├── MainToolBar.tsx
│ │ │ │ ├── MiniMapView.tsx
│ │ │ │ ├── PaletteView.tsx
│ │ │ │ ├── ToolSelector.tsx
│ │ │ │ ├── UndoRedo.tsx
│ │ │ │ └── ZoomControls.tsx
│ │ │ ├── touchScreen.ts
│ │ │ ├── zIndices.ts
│ │ │ └── zoom-shortcuts.ts
│ │ ├── board-navigation.ts
│ │ ├── components/
│ │ │ ├── BoardAccessPolicyEditor.tsx
│ │ │ ├── BoardCrdtModeSelector.tsx
│ │ │ ├── EditableSpan.tsx
│ │ │ ├── HTMLEditableSpan.tsx
│ │ │ ├── Icons.tsx
│ │ │ ├── ModalContainer.tsx
│ │ │ ├── UIColors.ts
│ │ │ ├── browser.ts
│ │ │ ├── components.tsx
│ │ │ ├── onClickOutside.tsx
│ │ │ └── sanitizeHTML.ts
│ │ ├── dashboard/
│ │ │ └── DashboardView.tsx
│ │ ├── embedding.tsx
│ │ ├── google-auth.ts
│ │ ├── index.tsx
│ │ ├── store/
│ │ │ ├── asset-store.ts
│ │ │ ├── board-local-store.ts
│ │ │ ├── board-store.test.ts
│ │ │ ├── board-store.ts
│ │ │ ├── crdt-store.ts
│ │ │ ├── cursors-store.ts
│ │ │ ├── recent-boards.ts
│ │ │ ├── server-connection.ts
│ │ │ └── user-session-store.ts
│ │ └── style/
│ │ ├── board.scss
│ │ ├── dashboard.scss
│ │ ├── global.scss
│ │ ├── header.scss
│ │ ├── modal.scss
│ │ ├── sharing-modal.scss
│ │ ├── tool-layer.scss
│ │ ├── user-info-modal.scss
│ │ ├── utils.scss
│ │ └── variables.scss
│ └── tsconfig.json
├── integration/
│ └── src/
│ └── compact-history.test.ts
├── keycloak/
│ ├── README.md
│ └── keycloak-db.dump
├── lint-staged.config.js
├── package.json
├── perf-tester/
│ ├── README.md
│ ├── package.json
│ ├── src/
│ │ ├── create-boards.ts
│ │ └── index.ts
│ └── tsconfig.json
├── playwright/
│ └── src/
│ ├── pages/
│ │ ├── BoardApi.ts
│ │ ├── BoardPage.ts
│ │ └── DashboardPage.ts
│ └── tests/
│ ├── accessPolicy.spec.ts
│ ├── api.spec.ts
│ ├── board.spec.ts
│ ├── collaboration.spec.ts
│ ├── dashboard.spec.ts
│ └── navigation.spec.ts
├── playwright.config.ts
├── scripts/
│ ├── migrate_user_email.sh
│ └── run_dockerized.sh
├── state-management.md
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
backend/dist
backend/localfiles
Dockerfile
frontend/.cache
ignore
latest.dump*
node_modules
npm-debug.log
**/node_modules
**/.env
**/yarn-error.log
**/.DS_Store
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
[package.json]
indent_size = 2
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.md
================================================
---
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.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
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.
================================================
FILE: .github/workflows/build.yml
================================================
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}}
================================================
FILE: .gitignore
================================================
.cache/
node_modules/
dist/
yarn-error.log
.env
*.mp4
backend/localfiles/
/ignore
.DS_Store
latest.dump
.vscode
backups
playwright/results
================================================
FILE: .huskyrc.js
================================================
module.exports = {
hooks: {
"pre-commit": "lint-staged",
},
}
================================================
FILE: .nvmrc
================================================
18
================================================
FILE: .prettierignore
================================================
package.json
.cache/
node_modules/
dist/
yarn-error.log
.env
*.mp4
backend/localfiles/
ignore/
================================================
FILE: .prettierrc.js
================================================
module.exports = {
printWidth: 120,
semi: false,
trailingComma: "all",
}
================================================
FILE: Dockerfile
================================================
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" ]
================================================
FILE: LICENSE.txt
================================================
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.
================================================
FILE: README.md
================================================
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)
================================================
FILE: backend/migrations/001_init.js
================================================
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();
`)
}
================================================
FILE: backend/migrations/002_add_first_serial.js
================================================
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;
`)
}
================================================
FILE: backend/migrations/003_refactor_access.sql
================================================
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
================================================
FILE: backend/migrations/004_public_boards_access.sql
================================================
update board
set public_read = 't',
public_write = 't'
where content -> 'accessPolicy' is null
================================================
FILE: backend/migrations/005_uuid_extension.sql
================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
================================================
FILE: backend/migrations/006_unique_email.sql
================================================
ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email);
================================================
FILE: backend/migrations/007_add_crdt_update.sql
================================================
alter table board_event add column crdt_update bytea null;
================================================
FILE: backend/migrations/008_allow_crdt_only_bundle.sql
================================================
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);
================================================
FILE: backend/migrations/009_drop_board_event_primary_key.sql
================================================
ALTER TABLE board_event DROP CONSTRAINT board_event_pkey;
CREATE INDEX board_event_board_index ON board_event (board_id);
================================================
FILE: backend/migrations/010_drop_history_column.sql
================================================
alter table board drop column history;
================================================
FILE: backend/package.json
================================================
{
"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"
}
}
================================================
FILE: backend/src/api/api-routes.ts
================================================
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,
)
================================================
FILE: backend/src/api/board-create.ts
================================================
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] })
})
================================================
FILE: backend/src/api/board-csv-get.ts
================================================
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` })
}
================================================
FILE: backend/src/api/board-get.ts
================================================
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 })
}),
)
================================================
FILE: backend/src/api/board-hierarchy-get.ts
================================================
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 }
}
================================================
FILE: backend/src/api/board-history-get.ts
================================================
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()
}
})
}
================================================
FILE: backend/src/api/board-update.ts
================================================
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 })
}),
)
================================================
FILE: backend/src/api/github-webhook.ts
================================================
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()
}
}
})
================================================
FILE: backend/src/api/item-create-or-update.ts
================================================
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`)
}
}
================================================
FILE: backend/src/api/item-create.ts
================================================
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)
}),
)
================================================
FILE: backend/src/api/utils.ts
================================================
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)
}
}
================================================
FILE: backend/src/board-event-handler.ts
================================================
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
}
================================================
FILE: backend/src/board-state.test.ts
================================================
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])
})
})
================================================
FILE: backend/src/board-state.ts
================================================
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)
================================================
FILE: backend/src/board-store.ts
================================================
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)
}
================================================
FILE: backend/src/board-yjs-server.ts
================================================
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()
}
})
}
================================================
FILE: backend/src/common-event-handler.ts
================================================
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
}
================================================
FILE: backend/src/compact-history.ts
================================================
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
}
}
================================================
FILE: backend/src/config.ts
================================================
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,
}
}
================================================
FILE: backend/src/connection-handler.ts
================================================
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
================================================
FILE: backend/src/db.ts
================================================
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()
}
}
================================================
FILE: backend/src/decodeOrThrow.ts
================================================
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")
}
================================================
FILE: backend/src/env.ts
================================================
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
}
================================================
FILE: backend/src/expiring-map.ts
================================================
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
}
================================================
FILE: backend/src/express-server.ts
================================================
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()
})
}
================================================
FILE: backend/src/generic-oidc-auth.ts
================================================
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),
}),
])
================================================
FILE: backend/src/github-webhook/example-payload.json
================================================
{
"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
}
}
================================================
FILE: backend/src/google-auth.ts
================================================
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,
}
}
================================================
FILE: backend/src/host-config.ts
================================================
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")
================================================
FILE: backend/src/http-session.ts
================================================
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 })
}
================================================
FILE: backend/src/locker.ts
================================================
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)
}
}
})
}
================================================
FILE: backend/src/oauth.ts
================================================
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
================================================
FILE: backend/src/professions.ts
================================================
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)]
}
================================================
FILE: backend/src/require-auth.ts
================================================
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()
}
})
}
}
================================================
FILE: backend/src/s3.ts
================================================
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
}
================================================
FILE: backend/src/server.ts
================================================
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)
})
================================================
FILE: backend/src/storage.ts
================================================
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
================================================
FILE: backend/src/tools/wait-for-db.ts
================================================
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)
}
})()
================================================
FILE: backend/src/user-store.ts
================================================
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(),
}
})
}
================================================
FILE: backend/src/uwebsockets-server.ts
================================================
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)
})
}
================================================
FILE: backend/src/websocket-sessions.ts
================================================
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
}
================================================
FILE: backend/src/ws-wrapper.ts
================================================
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
}
================================================
FILE: backend/src/y-websocket-server/Docs.ts
================================================
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)
}
}
================================================
FILE: backend/src/y-websocket-server/Persistence.ts
================================================
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) => {},
}
}
================================================
FILE: backend/src/y-websocket-server/Protocol.ts
================================================
export const messageSync = 0
export const messageAwareness = 1
================================================
FILE: backend/src/y-websocket-server/WSSharedDoc.ts
================================================
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)
}
}
================================================
FILE: backend/src/y-websocket-server/YWebSocketServer.ts
================================================
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])
}
}
================================================
FILE: backend/tsconfig.json
================================================
{
"extends": "../tsconfig",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"rootDir": "..",
"sourceMap": true
}
}
================================================
FILE: benchmark/benchmark.ts
================================================
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")
================================================
FILE: common/src/action-folding.ts
================================================
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)
}
================================================
FILE: common/src/arrays.ts
================================================
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)
}
================================================
FILE: common/src/assertNotNull.ts
================================================
export function assertNotNull<T>(x: T | null | undefined): T {
if (x === null || x === undefined) throw Error("Assertion failed: " + x)
return x
}
================================================
FILE: common/src/authenticated-user.ts
================================================
export type OAuthAuthenticatedUser = {
name: string
email: string
picture?: string
domain: string | null
}
================================================
FILE: common/src/board-crdt-helper.ts
================================================
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 ")
}
}
}
}
================================================
FILE: common/src/board-reducer.benchmark.ts
================================================
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)`)
================================================
FILE: common/src/board-reducer.ts
================================================
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})`
gitextract_96m72lw6/ ├── .dockerignore ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ └── feature_request.md │ └── workflows/ │ └── build.yml ├── .gitignore ├── .huskyrc.js ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── Dockerfile ├── LICENSE.txt ├── README.md ├── backend/ │ ├── migrations/ │ │ ├── 001_init.js │ │ ├── 002_add_first_serial.js │ │ ├── 003_refactor_access.sql │ │ ├── 004_public_boards_access.sql │ │ ├── 005_uuid_extension.sql │ │ ├── 006_unique_email.sql │ │ ├── 007_add_crdt_update.sql │ │ ├── 008_allow_crdt_only_bundle.sql │ │ ├── 009_drop_board_event_primary_key.sql │ │ └── 010_drop_history_column.sql │ ├── package.json │ ├── src/ │ │ ├── api/ │ │ │ ├── api-routes.ts │ │ │ ├── board-create.ts │ │ │ ├── board-csv-get.ts │ │ │ ├── board-get.ts │ │ │ ├── board-hierarchy-get.ts │ │ │ ├── board-history-get.ts │ │ │ ├── board-update.ts │ │ │ ├── github-webhook.ts │ │ │ ├── item-create-or-update.ts │ │ │ ├── item-create.ts │ │ │ └── utils.ts │ │ ├── board-event-handler.ts │ │ ├── board-state.test.ts │ │ ├── board-state.ts │ │ ├── board-store.ts │ │ ├── board-yjs-server.ts │ │ ├── common-event-handler.ts │ │ ├── compact-history.ts │ │ ├── config.ts │ │ ├── connection-handler.ts │ │ ├── db.ts │ │ ├── decodeOrThrow.ts │ │ ├── env.ts │ │ ├── expiring-map.ts │ │ ├── express-server.ts │ │ ├── generic-oidc-auth.ts │ │ ├── github-webhook/ │ │ │ └── example-payload.json │ │ ├── google-auth.ts │ │ ├── host-config.ts │ │ ├── http-session.ts │ │ ├── locker.ts │ │ ├── oauth.ts │ │ ├── professions.ts │ │ ├── require-auth.ts │ │ ├── s3.ts │ │ ├── server.ts │ │ ├── storage.ts │ │ ├── tools/ │ │ │ └── wait-for-db.ts │ │ ├── user-store.ts │ │ ├── uwebsockets-server.ts │ │ ├── websocket-sessions.ts │ │ ├── ws-wrapper.ts │ │ └── y-websocket-server/ │ │ ├── Docs.ts │ │ ├── Persistence.ts │ │ ├── Protocol.ts │ │ ├── WSSharedDoc.ts │ │ └── YWebSocketServer.ts │ └── tsconfig.json ├── benchmark/ │ └── benchmark.ts ├── common/ │ └── src/ │ ├── action-folding.ts │ ├── arrays.ts │ ├── assertNotNull.ts │ ├── authenticated-user.ts │ ├── board-crdt-helper.ts │ ├── board-reducer.benchmark.ts │ ├── board-reducer.ts │ ├── colors.ts │ ├── connection-utils.ts │ ├── domain.ts │ ├── geometry.ts │ ├── migration.test.ts │ ├── migration.ts │ ├── sets.ts │ ├── sleep.ts │ └── vector2.ts ├── cypress.json ├── docker-compose.yaml ├── frontend/ │ ├── .sassrc │ ├── esbuild.js │ ├── index.tmpl.html │ ├── package.json │ ├── src/ │ │ ├── app.scss │ │ ├── board/ │ │ │ ├── BoardView.tsx │ │ │ ├── BoardViewMessage.tsx │ │ │ ├── CollaborativeTextView.tsx │ │ │ ├── ConnectionsView.tsx │ │ │ ├── CursorsView.tsx │ │ │ ├── DragBorder.tsx │ │ │ ├── ImageView.tsx │ │ │ ├── ItemView.tsx │ │ │ ├── RectangularDragSelection.tsx │ │ │ ├── SaveAsTemplate.tsx │ │ │ ├── SelectionBorder.tsx │ │ │ ├── TextView.tsx │ │ │ ├── VideoView.tsx │ │ │ ├── autoFontSize.ts │ │ │ ├── board-coordinates.ts │ │ │ ├── board-drag.ts │ │ │ ├── board-focus.ts │ │ │ ├── board-permissions.ts │ │ │ ├── board-scroll-and-zoom.ts │ │ │ ├── boardContentArea.ts │ │ │ ├── contextmenu/ │ │ │ │ ├── ContextMenuView.tsx │ │ │ │ ├── alignments.tsx │ │ │ │ ├── areaTiling.tsx │ │ │ │ ├── colors.tsx │ │ │ │ ├── colorsAndShapes.tsx │ │ │ │ ├── connection-ends.tsx │ │ │ │ ├── fontSizes.tsx │ │ │ │ ├── hideContents.tsx │ │ │ │ ├── lock.tsx │ │ │ │ ├── shapes.tsx │ │ │ │ ├── textAlignments.tsx │ │ │ │ └── textFormats.tsx │ │ │ ├── contrasting-color.ts │ │ │ ├── double-click.ts │ │ │ ├── header/ │ │ │ │ ├── BoardViewHeader.tsx │ │ │ │ ├── OtherUsersView.tsx │ │ │ │ ├── SharingModalDialog.tsx │ │ │ │ ├── UserInfoModal.tsx │ │ │ │ └── UserInfoView.tsx │ │ │ ├── image-upload.ts │ │ │ ├── item-connect.ts │ │ │ ├── item-create.ts │ │ │ ├── item-cut-copy-paste.ts │ │ │ ├── item-delete.ts │ │ │ ├── item-drag.ts │ │ │ ├── item-dragmove.ts │ │ │ ├── item-duplicate.ts │ │ │ ├── item-hide-contents.ts │ │ │ ├── item-move-with-arrow-keys.ts │ │ │ ├── item-organizer.test.ts │ │ │ ├── item-organizer.ts │ │ │ ├── item-packer.ts │ │ │ ├── item-select-all.ts │ │ │ ├── item-selection.ts │ │ │ ├── item-setcontainer.ts │ │ │ ├── item-undo-redo.ts │ │ │ ├── keyboard-shortcuts.ts │ │ │ ├── local-storage-atom.ts │ │ │ ├── quillClickableLink.ts │ │ │ ├── quillPasteLinkOverText.ts │ │ │ ├── synchronize-focus-with-server.ts │ │ │ ├── tool-selection.ts │ │ │ ├── toolbars/ │ │ │ │ ├── BackToAllBoardsLink.tsx │ │ │ │ ├── BoardToolLayer.tsx │ │ │ │ ├── MainToolBar.tsx │ │ │ │ ├── MiniMapView.tsx │ │ │ │ ├── PaletteView.tsx │ │ │ │ ├── ToolSelector.tsx │ │ │ │ ├── UndoRedo.tsx │ │ │ │ └── ZoomControls.tsx │ │ │ ├── touchScreen.ts │ │ │ ├── zIndices.ts │ │ │ └── zoom-shortcuts.ts │ │ ├── board-navigation.ts │ │ ├── components/ │ │ │ ├── BoardAccessPolicyEditor.tsx │ │ │ ├── BoardCrdtModeSelector.tsx │ │ │ ├── EditableSpan.tsx │ │ │ ├── HTMLEditableSpan.tsx │ │ │ ├── Icons.tsx │ │ │ ├── ModalContainer.tsx │ │ │ ├── UIColors.ts │ │ │ ├── browser.ts │ │ │ ├── components.tsx │ │ │ ├── onClickOutside.tsx │ │ │ └── sanitizeHTML.ts │ │ ├── dashboard/ │ │ │ └── DashboardView.tsx │ │ ├── embedding.tsx │ │ ├── google-auth.ts │ │ ├── index.tsx │ │ ├── store/ │ │ │ ├── asset-store.ts │ │ │ ├── board-local-store.ts │ │ │ ├── board-store.test.ts │ │ │ ├── board-store.ts │ │ │ ├── crdt-store.ts │ │ │ ├── cursors-store.ts │ │ │ ├── recent-boards.ts │ │ │ ├── server-connection.ts │ │ │ └── user-session-store.ts │ │ └── style/ │ │ ├── board.scss │ │ ├── dashboard.scss │ │ ├── global.scss │ │ ├── header.scss │ │ ├── modal.scss │ │ ├── sharing-modal.scss │ │ ├── tool-layer.scss │ │ ├── user-info-modal.scss │ │ ├── utils.scss │ │ └── variables.scss │ └── tsconfig.json ├── integration/ │ └── src/ │ └── compact-history.test.ts ├── keycloak/ │ ├── README.md │ └── keycloak-db.dump ├── lint-staged.config.js ├── package.json ├── perf-tester/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── create-boards.ts │ │ └── index.ts │ └── tsconfig.json ├── playwright/ │ └── src/ │ ├── pages/ │ │ ├── BoardApi.ts │ │ ├── BoardPage.ts │ │ └── DashboardPage.ts │ └── tests/ │ ├── accessPolicy.spec.ts │ ├── api.spec.ts │ ├── board.spec.ts │ ├── collaboration.spec.ts │ ├── dashboard.spec.ts │ └── navigation.spec.ts ├── playwright.config.ts ├── scripts/ │ ├── migrate_user_email.sh │ └── run_dockerized.sh ├── state-management.md ├── tsconfig.json └── vitest.config.ts
SYMBOL INDEX (752 symbols across 147 files)
FILE: backend/migrations/003_refactor_access.sql
type board_access (line 3) | create table board_access (
FILE: backend/migrations/009_drop_board_event_primary_key.sql
type board_event_board_index (line 2) | CREATE INDEX board_event_board_index ON board_event (board_id)
FILE: backend/src/api/board-csv-get.ts
type Row (line 49) | type Row = { parents: Container[]; rowContainer: Container; textItems: T...
function csv (line 59) | function csv(board: Board, rows: string[][]) {
FILE: backend/src/api/board-hierarchy-get.ts
type ItemHierarchy (line 25) | type ItemHierarchy = Item & { children: ItemHierarchy[] }
function getBoardHierarchy (line 26) | function getBoardHierarchy(board: Board) {
FILE: backend/src/api/board-history-get.ts
function streamingJSONBody (line 27) | function streamingJSONBody(fieldName: string, generator: (callback: (ite...
FILE: backend/src/api/item-create-or-update.ts
function updateItem (line 88) | function updateItem(
FILE: backend/src/api/utils.ts
function checkBoardAPIAccess (line 26) | async function checkBoardAPIAccess<T>(
function findContainer (line 55) | function findContainer(container: string | undefined, board: Board): Con...
function getItemAttributesForContainer (line 72) | function getItemAttributesForContainer(container: string | undefined, bo...
function dispatchSystemAppEvent (line 84) | function dispatchSystemAppEvent(board: ServerSideBoardState, appEvent: P...
function addItem (line 94) | function addItem(
class InvalidRequest (line 120) | class InvalidRequest extends Error {
method constructor (line 121) | constructor(message: string) {
FILE: backend/src/board-state.ts
type ServerSideBoardState (line 13) | type ServerSideBoardState = {
type ServerSideBoardStateInternal (line 29) | type ServerSideBoardStateInternal =
function getBoard (line 38) | async function getBoard(id: Id): Promise<ServerSideBoardState | null> {
function maybeGetBoard (line 88) | function maybeGetBoard(id: Id): ServerSideBoardState | undefined {
function updateBoards (line 93) | function updateBoards(boardState: ServerSideBoardState, appEvent: BoardH...
function updateBoardCrdt (line 108) | function updateBoardCrdt(id: Id, crdtUpdate: Uint8Array) {
function addBoard (line 118) | async function addBoard(board: Board, createToken?: boolean): Promise<Se...
function getActiveBoards (line 138) | function getActiveBoards() {
function saveBoards (line 144) | async function saveBoards() {
function awaitSavingChanges (line 152) | async function awaitSavingChanges() {
function saveBoardChanges (line 156) | async function saveBoardChanges(state: ServerSideBoardState) {
function combineCrdtUpdates (line 193) | function combineCrdtUpdates(a: Uint8Array | null, b: Uint8Array | null) {
function getActiveBoardCount (line 199) | function getActiveBoardCount() {
FILE: backend/src/board-store.ts
type BoardAndAccessTokens (line 10) | type BoardAndAccessTokens = {
type BoardInfo (line 15) | type BoardInfo = {
function getBoardInfo (line 21) | async function getBoardInfo(id: Id): Promise<BoardInfo | null> {
function fetchBoard (line 45) | async function fetchBoard(id: Id): Promise<BoardAndAccessTokens | null> {
function createBoard (line 127) | async function createBoard(board: Board): Promise<void> {
function updateAccessPolicy (line 147) | async function updateAccessPolicy(boardId: string, accessPolicy: BoardAc...
function renameBoardConvenienceColumnOnly (line 171) | async function renameBoardConvenienceColumnOnly(boardId: Id, name: strin...
function updateBoardAccessPolicy (line 179) | async function updateBoardAccessPolicy(boardId: Id, accessPolicy: BoardA...
function createAccessToken (line 187) | async function createAccessToken(board: Board): Promise<string> {
type StreamingBoardEventCallback (line 195) | type StreamingBoardEventCallback = (chunk: BoardHistoryEntry[]) => void
function streamingBoardEventsQuery (line 199) | function streamingBoardEventsQuery(text: string, values: any[], client: ...
function getFullBoardHistory (line 223) | function getFullBoardHistory(id: Id, client: PoolClient, cb: StreamingBo...
function getBoardHistory (line 237) | async function getBoardHistory(id: Id, afterSerial: Serial, cb: Streamin...
function verifyContinuity (line 302) | function verifyContinuity(boardId: Id, init: Serial, ...histories: Board...
function verifyEventArrayContinuity (line 314) | function verifyEventArrayContinuity(boardId: Id, init: Serial, events: B...
function verifyTwoPoints (line 324) | function verifyTwoPoints(boardId: Id, a: Serial, b: Serial) {
function mkSnapshot (line 332) | function mkSnapshot(board: Board, serial: Serial) {
function saveBoardSnapshot (line 337) | async function saveBoardSnapshot(board: Board, client: PoolClient) {
function storeEventHistoryBundle (line 348) | async function storeEventHistoryBundle(
function storeCRDTOnlyEventHistoryBundle (line 375) | async function storeCRDTOnlyEventHistoryBundle(
type BoardHistoryBundle (line 391) | type BoardHistoryBundle = {
function getBoardHistoryBundlesWithLastSerialsBetween (line 400) | async function getBoardHistoryBundlesWithLastSerialsBetween(
function getBoardHistoryCrdtUpdates (line 419) | async function getBoardHistoryCrdtUpdates(client: PoolClient, id: Id): P...
function migrateBundle (line 433) | function migrateBundle(b: BoardHistoryBundle): BoardHistoryBundle {
type BoardHistoryBundleMeta (line 437) | type BoardHistoryBundleMeta = {
function getBoardHistoryBundleMetas (line 444) | async function getBoardHistoryBundleMetas(client: PoolClient, id: Id): P...
function verifyContinuityFromMetas (line 458) | function verifyContinuityFromMetas(boardId: Id, init: Serial, bundles: B...
function findAllBoards (line 478) | async function findAllBoards(client: PoolClient): Promise<Id[]> {
FILE: backend/src/board-yjs-server.ts
function closeYjsSocketsBySessionId (line 14) | function closeYjsSocketsBySessionId(sessionId: string) {
function BoardYJSServer (line 56) | function BoardYJSServer(ws: expressWs.Instance, path: string) {
FILE: backend/src/common-event-handler.ts
function handleCommonEvent (line 17) | async function handleCommonEvent(socket: WsWrapper, appEvent: AppEvent):...
FILE: backend/src/compact-history.ts
function chunkBy (line 16) | function chunkBy<T>(arr: T[], shouldSplit: (a: T, b: T) => boolean) {
function getHour (line 29) | function getHour(b: BoardHistoryBundleMeta) {
function quickCompactBoardHistory (line 33) | async function quickCompactBoardHistory(id: Id): Promise<number> {
FILE: backend/src/config.ts
type StorageBackend (line 8) | type StorageBackend = Readonly<
type Config (line 11) | type Config = Readonly<{ storageBackend: StorageBackend; authSupported: ...
type CrdtConfigString (line 19) | type CrdtConfigString = t.TypeOf<typeof CrdtConfigString>
FILE: backend/src/connection-handler.ts
type ConnectionHandlerParams (line 8) | type ConnectionHandlerParams = Readonly<{
type MessageHandler (line 67) | type MessageHandler = (socket: WsWrapper, appEvent: AppEvent) => Promise...
type MessageHandlerResult (line 68) | type MessageHandlerResult = { boardId: Id; serial: Serial } | boolean
FILE: backend/src/db.ts
constant DATABASE_URL (line 5) | const DATABASE_URL = process.env.DATABASE_URL ?? "postgres://r-board:sec...
constant DATABASE_SSL_ENABLED (line 6) | const DATABASE_SSL_ENABLED = process.env.DATABASE_SSL_ENABLED === "true"
function closeConnectionPool (line 18) | function closeConnectionPool() {
function initDB (line 22) | async function initDB(backendDir: string = ".") {
function withDBClient (line 45) | async function withDBClient<T>(f: (client: PoolClient) => Promise<T>): P...
function inTransaction (line 56) | async function inTransaction<T>(f: (client: PoolClient) => Promise<T>): ...
FILE: backend/src/decodeOrThrow.ts
function decodeOrThrow (line 5) | function decodeOrThrow<T>(codec: t.Type<T, any>, input: any): T {
class ValidationError (line 13) | class ValidationError extends Error {
method constructor (line 14) | constructor(errors: Left<t.Errors>) {
function report_ (line 19) | function report_(errors: t.Errors) {
FILE: backend/src/env.ts
function getEnv (line 3) | function getEnv(name: string): string {
FILE: backend/src/expiring-map.ts
function AutoExpiringMap (line 1) | function AutoExpiringMap<V extends any>(ttlSeconds: number) {
FILE: backend/src/express-server.ts
function startWs (line 143) | function startWs(http: any, app: express.Express) {
FILE: backend/src/generic-oidc-auth.ts
type GenericOAuthConfig (line 12) | type GenericOAuthConfig = {
function GenericOIDCAuthProvider (line 28) | function GenericOIDCAuthProvider(config: GenericOAuthConfig): AuthProvid...
FILE: backend/src/google-auth.ts
type GoogleConfig (line 12) | type GoogleConfig = {
function googleOAUTH2 (line 31) | function googleOAUTH2() {
function getAuthPageURL (line 37) | async function getAuthPageURL() {
function getAccountFromCode (line 52) | async function getAccountFromCode(code: string): Promise<OAuthAuthentica...
FILE: backend/src/host-config.ts
constant ROOT_URL (line 1) | const ROOT_URL = process.env.ROOT_URL ?? "http://localhost:1337"
constant ROOT_HOST (line 2) | const ROOT_HOST = new URL(ROOT_URL).host
constant ROOT_PROTOCOL (line 3) | const ROOT_PROTOCOL = new URL(ROOT_URL).protocol
constant WS_HOST_LOCAL (line 4) | const WS_HOST_LOCAL = (process.env.WS_HOST_LOCAL ?? ROOT_HOST).split(",")
constant WS_HOST_DEFAULT (line 5) | const WS_HOST_DEFAULT = process.env.WS_HOST_DEFAULT ?? ROOT_HOST
constant WS_PROTOCOL (line 6) | const WS_PROTOCOL = process.env.WS_PROTOCOL ?? (ROOT_PROTOCOL.startsWith...
FILE: backend/src/http-session.ts
type LoginInfo (line 10) | type LoginInfo = OAuthAuthenticatedUser & {
function getSessionIdFromCookies (line 14) | function getSessionIdFromCookies(req: IncomingMessage): string | null {
function getAuthenticatedUser (line 19) | function getAuthenticatedUser(req: IncomingMessage): LoginInfo | null {
function getAuthenticatedUserFromJWT (line 27) | function getAuthenticatedUserFromJWT(jwt: string): LoginInfo | null {
function setAuthenticatedUser (line 42) | function setAuthenticatedUser(req: IncomingMessage, res: ServerResponse,...
function removeAuthenticatedUser (line 51) | function removeAuthenticatedUser(req: IncomingMessage, res: ServerRespon...
FILE: backend/src/locker.ts
constant LOCK_TTL_SECONDS (line 6) | const LOCK_TTL_SECONDS = 10
function Locks (line 8) | function Locks(onChange: (locks: ItemLocks) => any) {
function obtainLock (line 32) | function obtainLock(locks: ServerSideBoardState["locks"], e: BoardItemEv...
function releaseLocksFor (line 49) | function releaseLocksFor(socket: WsWrapper) {
FILE: backend/src/oauth.ts
type AuthProvider (line 8) | interface AuthProvider {
function setupAuth (line 14) | function setupAuth(app: Express, provider: AuthProvider) {
FILE: backend/src/professions.ts
function randomProfession (line 70) | function randomProfession() {
FILE: backend/src/require-auth.ts
constant REQUIRE_AUTH (line 4) | const REQUIRE_AUTH = process.env.REQUIRE_AUTH === "true"
function possiblyRequireAuth (line 6) | function possiblyRequireAuth(app: Express) {
FILE: backend/src/s3.ts
function getSignedPutUrl (line 16) | function getSignedPutUrl(Key: string) {
FILE: backend/src/server.ts
function shutdown (line 14) | async function shutdown() {
constant PORT (line 29) | const PORT = parseInt(process.env.PORT || "1337")
constant HTTPS_PORT (line 30) | const HTTPS_PORT = process.env.HTTPS_PORT ? parseInt(process.env.HTTPS_P...
constant BIND_UWEBSOCKETS_TO_PORT (line 31) | const BIND_UWEBSOCKETS_TO_PORT = process.env.BIND_UWEBSOCKETS_TO_PORT ==...
constant HTTP_PORT (line 35) | const HTTP_PORT = BIND_UWEBSOCKETS_TO_PORT ? null : PORT
constant UWEBSOCKETS_PORT (line 36) | const UWEBSOCKETS_PORT = BIND_UWEBSOCKETS_TO_PORT
FILE: backend/src/storage.ts
function localFSGetSignedPutUrl (line 4) | function localFSGetSignedPutUrl(Key: string): string {
FILE: backend/src/user-store.ts
function getUserIdForEmail (line 6) | function getUserIdForEmail(email: string): Promise<string> {
function associateUserWithBoard (line 17) | async function associateUserWithBoard(
function dissociateUserWithBoard (line 35) | async function dissociateUserWithBoard(userId: string, boardId: Id) {
function getUserAssociatedBoards (line 45) | async function getUserAssociatedBoards(user: EventUserInfoAuthenticated)...
FILE: backend/src/uwebsockets-server.ts
type WsWrapper (line 46) | type WsWrapper = ReturnType<typeof WsWrapper>
type WsUserData (line 48) | type WsUserData = {
function startUWebSocketsServer (line 52) | function startUWebSocketsServer(port: number) {
FILE: backend/src/websocket-sessions.ts
type UserSession (line 29) | type UserSession = {
type UserSessionBoardEntry (line 38) | type UserSessionBoardEntry = {
type SocketId (line 50) | type SocketId = string
function startSession (line 68) | function startSession(socket: WsWrapper) {
function userSession (line 72) | function userSession(socket: WsWrapper): UserSession {
function anonymousUser (line 98) | function anonymousUser(nickname: string): UnidentifiedUserInfo {
function endSession (line 102) | function endSession(socket: WsWrapper) {
function getBoardSessionCount (line 121) | function getBoardSessionCount(id: Id) {
function getSession (line 124) | function getSession(socket: WsWrapper): UserSession | undefined {
function getSessionById (line 128) | function getSessionById(sessionId: string): UserSession | undefined {
function terminateSessions (line 132) | function terminateSessions() {
function addSessionToBoard (line 136) | async function addSessionToBoard(
function initAsNew (line 241) | function initAsNew(session: UserSession, boardId: string, accessLevel: A...
function setNicknameForSession (line 250) | function setNicknameForSession(event: SetNickname, origin: WsWrapper) {
function setVerifiedUserForSession (line 271) | async function setVerifiedUserForSession(
function logoutUser (line 297) | function logoutUser(event: AuthLogout, origin: WsWrapper) {
function broadcastBoardEvent (line 306) | function broadcastBoardEvent(event: EventFromServer & { boardId: string ...
function broadcastJoinEvent (line 311) | function broadcastJoinEvent(boardId: Id, session: UserSession) {
function broadcastCursorPositions (line 320) | function broadcastCursorPositions(boardId: Id, positions: Record<Id, Use...
constant BROADCAST_DEBOUNCE_MS (line 324) | const BROADCAST_DEBOUNCE_MS = 20
function getSessionCount (line 345) | function getSessionCount() {
FILE: backend/src/ws-wrapper.ts
type WsWrapper (line 37) | type WsWrapper = ReturnType<typeof WsWrapper>
type CachedBuffer (line 39) | type CachedBuffer = { msg: EventFromServer; buffer: Buffer }
function toBuffer (line 41) | function toBuffer(msg: EventFromServer) {
FILE: backend/src/y-websocket-server/Docs.ts
type DocsOptions (line 4) | interface DocsOptions {
type DocState (line 9) | interface DocState {
class Docs (line 14) | class Docs {
method constructor (line 19) | constructor(options: DocsOptions = {}) {
method getYDoc (line 27) | getYDoc(docname: string): WSSharedDoc {
method getYDocAndWaitForFetch (line 31) | async getYDocAndWaitForFetch(docname: string): Promise<WSSharedDoc> {
method getDocState (line 37) | private getDocState(docname: string): DocState {
method deleteYDoc (line 54) | deleteYDoc(doc: WSSharedDoc) {
FILE: backend/src/y-websocket-server/Persistence.ts
type Persistence (line 3) | interface Persistence {
function createLevelDbPersistence (line 8) | function createLevelDbPersistence(persistenceDir: string): Persistence {
FILE: backend/src/y-websocket-server/WSSharedDoc.ts
class WSSharedDoc (line 16) | class WSSharedDoc extends Y.Doc {
method constructor (line 22) | constructor(docs: Docs, name: string) {
method send (line 68) | send(conn: WebSocket, m: Uint8Array) {
method closeConn (line 82) | closeConn(conn: WebSocket) {
method addConnection (line 98) | addConnection(conn: WebSocket) {
method hasConnection (line 102) | hasConnection(conn: WebSocket) {
FILE: backend/src/y-websocket-server/YWebSocketServer.ts
class YWebSocketServer (line 13) | class YWebSocketServer {
method constructor (line 15) | constructor(options?: DocsOptions) {
method setupWSConnection (line 19) | async setupWSConnection(conn: WebSocket, docName: string, readOnly: bo...
FILE: benchmark/benchmark.ts
type Foo (line 6) | type Foo = Board
function createRandomItems (line 8) | function createRandomItems(count: number): Item[] {
function createTestBoard (line 27) | function createTestBoard(size = 1000): Board {
FILE: common/src/action-folding.ts
type FoldOptions (line 11) | type FoldOptions = {
constant CURSORS_ONLY (line 20) | const CURSORS_ONLY: FoldOptions = { cursorsOnly: true }
function foldActions (line 22) | function foldActions(a: AppEvent, b: AppEvent, options: FoldOptions = de...
function foldActions_ (line 39) | function foldActions_(a: AppEvent, b: AppEvent, options: FoldOptions = d...
function everyMovedItemMatches (line 74) | function everyMovedItemMatches(evt: MoveItem, evt2: MoveItem) {
function addOrReplaceEvent (line 78) | function addOrReplaceEvent<E extends AppEvent>(event: E, q: E[], options...
FILE: common/src/arrays.ts
function toArray (line 3) | function toArray<T>(x: T | T[]) {
function arrayIdMatch (line 8) | function arrayIdMatch<T extends { id: string }>(a: T[] | T, b: T[] | T) {
function arrayObjectKeysMatch (line 12) | function arrayObjectKeysMatch<T extends object>(a: T[] | T, b: T[] | T) {
function arrayIdAndKeysMatch (line 16) | function arrayIdAndKeysMatch<T extends { id: string }>(a: T[] | T, b: T[...
function idsOf (line 20) | function idsOf<T extends { id: string }>(a: T[] | T): string[] {
function keysOf (line 24) | function keysOf<T extends object>(a: T[] | T): string[][] {
function arrayEquals (line 28) | function arrayEquals<T>(a: T[] | T, b: T[] | T) {
function arrayToRecordById (line 32) | function arrayToRecordById<T extends { id: string }>(arr: T[], init: Rec...
FILE: common/src/assertNotNull.ts
function assertNotNull (line 1) | function assertNotNull<T>(x: T | null | undefined): T {
FILE: common/src/authenticated-user.ts
type OAuthAuthenticatedUser (line 1) | type OAuthAuthenticatedUser = {
FILE: common/src/board-crdt-helper.ts
function getCRDTField (line 4) | function getCRDTField(doc: Y.Doc, itemId: Id, fieldName: string) {
function augmentBoardWithCRDT (line 8) | function augmentBoardWithCRDT(doc: Y.Doc, board: Board): Board {
function augmentItemsWithCRDT (line 16) | function augmentItemsWithCRDT(doc: Y.Doc, items: Item[]): Item[] {
function importItemsIntoCRDT (line 28) | function importItemsIntoCRDT(doc: Y.Doc, items: Item[], options?: { fall...
FILE: common/src/board-reducer.benchmark.ts
function createBoard (line 8) | function createBoard(): Board {
FILE: common/src/board-reducer.ts
type BoardReducerOptions (line 47) | type BoardReducerOptions = {
function boardReducer (line 52) | function boardReducer(
function copyMatchingKeysFromOriginal (line 349) | function copyMatchingKeysFromOriginal<T extends { id: Id }>(update: Upda...
function validateConnection (line 355) | function validateConnection(board: Board, connection: Connection) {
function validateEndPoint (line 360) | function validateEndPoint(board: Board, connection: Connection, key: "to...
function updateConnections (line 370) | function updateConnections(board: Board, updates: ConnectionUpdate[]): C...
function updateItems (line 388) | function updateItems(
function applyModification (line 469) | function applyModification<T>(
function applyListModification (line 479) | function applyListModification<T>(list: T[], modification: (list: T[]) =...
function replaceById (line 485) | function replaceById<T extends { id: Id }>(list: T[], replacements: T[]) {
function applyFontSize (line 495) | function applyFontSize(items: Record<string, Item>, factor: number, item...
function filterItemIdsByPermissions (line 513) | function filterItemIdsByPermissions(itemIds: Id[], board: Board, permiss...
function filterConnectionIdsByPermissions (line 517) | function filterConnectionIdsByPermissions(connectionIds: Id[], board: Bo...
function filterMoveByPermissions (line 521) | function filterMoveByPermissions(event: MoveItem, board: Board) {
function filterItemUpdatesByPermissions (line 529) | function filterItemUpdatesByPermissions(updates: ItemUpdate[], board: Bo...
function filterConnectionUpdatesByPermissions (line 556) | function filterConnectionUpdatesByPermissions(updates: ConnectionUpdate[...
function moveItems (line 578) | function moveItems(board: Board, event: MoveItem, inplace: boolean) {
type Move (line 664) | type Move = { xDiff: number; yDiff: number }
type ItemMove (line 665) | type ItemMove = Move & { containerChanged: boolean; containerId: Id | un...
type ConnectionMove (line 666) | type ConnectionMove = (Move & { ends: "both" }) | { ends: "one" }
function findConnectionMove (line 668) | function findConnectionMove(
function moveEndPoint (line 697) | function moveEndPoint(endPoint: ConnectionEndPoint, move: Move) {
function containedBy (line 706) | function containedBy(a: Point, b: Rect) {
FILE: common/src/colors.ts
constant LIGHT_BLUE (line 1) | const LIGHT_BLUE = "#9FECFC"
constant LIGHT_GREEN (line 2) | const LIGHT_GREEN = "#C8FC87"
constant YELLOW (line 3) | const YELLOW = "#FBFC86"
constant ORANGE (line 4) | const ORANGE = "#FDDF90"
constant PINK (line 5) | const PINK = "#FDC4E7"
constant LIGHT_PURPLE (line 6) | const LIGHT_PURPLE = "#E0BDFA"
constant RED (line 7) | const RED = "#F62A5C"
constant BLACK (line 8) | const BLACK = "#000000"
constant LIGHT_GRAY (line 9) | const LIGHT_GRAY = "#f4f4f6"
constant WHITE (line 10) | const WHITE = "#ffffff"
constant TRANSPARENT (line 11) | const TRANSPARENT = "#ffffff00"
constant DEFAULT_NOTE_COLOR (line 12) | const DEFAULT_NOTE_COLOR = YELLOW
constant NOTE_COLORS (line 14) | const NOTE_COLORS = [
FILE: common/src/connection-utils.ts
function resolveEndpoint (line 21) | function resolveEndpoint(e: Point | Item | ConnectionEndPoint, b: Board ...
function resolveItemEndpoint (line 28) | function resolveItemEndpoint(e: ConnectionEndPointToItem, b: Board | Rec...
function findNearestAttachmentLocationForConnectionNode (line 32) | function findNearestAttachmentLocationForConnectionNode(
function angleDiff (line 42) | function angleDiff(option: ItemAttachmentLocation, from: Point) {
function withStraightestAngle (line 50) | function withStraightestAngle(options: ItemAttachmentLocation[], to: Poi...
function getEndPointDirection (line 54) | function getEndPointDirection(side: AttachmentSide): Vector2 {
function findItemAttachmentLocations (line 69) | function findItemAttachmentLocations(i: Item): ItemAttachmentLocation[] {
function p (line 73) | function p(x: number, y: number) {
function findAttachmentLocation (line 77) | function findAttachmentLocation(i: Item, side: AttachmentSide): ItemAtta...
function findMidpoint (line 91) | function findMidpoint(fromCoords: AttachmentLocation, toCoords: Attachme...
function attachmentLocation2EndPoint (line 123) | function attachmentLocation2EndPoint(l: AttachmentLocation): ConnectionE...
function rerouteConnection (line 130) | function rerouteConnection(c: Connection, b: Board): Connection {
function rerouteEndPoint (line 150) | function rerouteEndPoint(e: ConnectionEndPoint, from: ConnectionEndPoint...
function rerouteByNewControlPoints (line 156) | function rerouteByNewControlPoints(c: Connection, controlPoints: Point[]...
function mid (line 167) | function mid(x: number, y: number) {
function isFullyContainedConnection (line 189) | function isFullyContainedConnection(connection: Connection, item: Item, ...
FILE: common/src/domain.ts
type Id (line 8) | type Id = string
type ISOTimeStamp (line 9) | type ISOTimeStamp = string
function newISOTimeStamp (line 11) | function newISOTimeStamp(): ISOTimeStamp {
function optional (line 15) | function optional<T extends t.Type<any>>(c: T) {
type CrdtMode (line 21) | type CrdtMode = typeof CrdtDisabled | typeof CrdtEnabled
type BoardAttributes (line 23) | type BoardAttributes = {
type BoardContents (line 32) | type BoardContents = {
type Board (line 37) | type Board = BoardAttributes &
type BoardStub (line 42) | type BoardStub = Pick<Board, "id" | "name" | "accessPolicy" | "crdt"> & ...
type AccessLevel (line 50) | type AccessLevel = t.TypeOf<typeof AccessLevelCodec>
type AccessListEntry (line 61) | type AccessListEntry = t.TypeOf<typeof AccessListEntryCodec>
type BoardAccessPolicyDefined (line 67) | type BoardAccessPolicyDefined = t.TypeOf<typeof BoardAccessPolicyDefined...
type BoardAccessPolicy (line 69) | type BoardAccessPolicy = t.TypeOf<typeof BoardAccessPolicyCodec>
type EventUserInfo (line 71) | type EventUserInfo = UnidentifiedUserInfo | SystemUserInfo | EventUserIn...
type UnidentifiedUserInfo (line 73) | type UnidentifiedUserInfo = { nickname: string; userType: "unidentified" }
type SystemUserInfo (line 74) | type SystemUserInfo = { nickname: string; userType: "system" }
type EventUserInfoAuthenticated (line 76) | type EventUserInfoAuthenticated = {
type SessionUserInfo (line 84) | type SessionUserInfo = UnidentifiedUserInfo | SystemUserInfo | SessionUs...
type SessionUserInfoAuthenticated (line 86) | type SessionUserInfoAuthenticated = {
type UserSessionInfo (line 96) | type UserSessionInfo = SessionUserInfo & {
type BoardHistoryEntry (line 100) | type BoardHistoryEntry = {
type BoardWithHistory (line 106) | type BoardWithHistory = { board: Board; history: BoardHistoryEntry[] }
type CompactBoardHistory (line 107) | type CompactBoardHistory = { boardAttributes: BoardAttributes; history: ...
type CursorPosition (line 111) | interface CursorPosition {
type UserCursorPosition (line 116) | type UserCursorPosition = CursorPosition & {
type BoardCursorPositions (line 120) | type BoardCursorPositions = Record<Id, UserCursorPosition>
type Color (line 122) | type Color = string
type ItemBounds (line 124) | type ItemBounds = { x: number; y: number; width: number; height: number;...
type LockState (line 125) | type LockState = false | "locked" | "read-only"
type ItemProperties (line 126) | type ItemProperties = { id: string; containerId?: string; locked: LockSt...
constant ITEM_TYPES (line 128) | const ITEM_TYPES = {
type ItemType (line 135) | type ItemType = typeof ITEM_TYPES[keyof typeof ITEM_TYPES]
type QuillDelta (line 136) | type QuillDelta = any // TODO: define this properly
type TextItemProperties (line 137) | type TextItemProperties = ItemProperties & {
type NoteShape (line 144) | type NoteShape = "round" | "square" | "rect" | "diamond"
type Note (line 145) | type Note = TextItemProperties & {
type Text (line 150) | type Text = TextItemProperties & { type: typeof ITEM_TYPES.TEXT; color: ...
type Image (line 151) | type Image = ItemProperties & { type: typeof ITEM_TYPES.IMAGE; assetId: ...
type Video (line 152) | type Video = ItemProperties & { type: typeof ITEM_TYPES.VIDEO; assetId: ...
type Container (line 153) | type Container = TextItemProperties & {
type Point (line 159) | type Point = { x: number; y: number }
function Point (line 160) | function Point(x: number, y: number) {
type ConnectionEndStyle (line 164) | type ConnectionEndStyle = "none" | "arrow" | "black-dot"
type Connection (line 165) | type Connection = {
type ConnectionEndPoint (line 178) | type ConnectionEndPoint = Point | ConnectionEndPointToItem
type ConnectionEndPointToItem (line 179) | type ConnectionEndPointToItem = Id | ConectionEndPointDirectedToItem
type ConectionEndPointDirectedToItem (line 180) | type ConectionEndPointDirectedToItem = { id: Id; side: AttachmentSide }
function getEndPointItemId (line 181) | function getEndPointItemId(e: ConnectionEndPointToItem) {
function isItemEndPoint (line 185) | function isItemEndPoint(e: ConnectionEndPoint): e is ConnectionEndPointT...
function isDirectedItemEndPoint (line 190) | function isDirectedItemEndPoint(e: ConnectionEndPoint): e is ConectionEn...
type AttachmentSide (line 193) | type AttachmentSide = "left" | "right" | "top" | "bottom"
type AttachmentLocation (line 194) | type AttachmentLocation = { side: "none"; point: Point } | ItemAttachmen...
type ItemAttachmentLocation (line 195) | type ItemAttachmentLocation = { side: AttachmentSide; point: Point; item...
type RenderableConnection (line 197) | type RenderableConnection = Omit<Connection, "from" | "to"> & {
type TextItem (line 202) | type TextItem = Note | Text | Container
type ColoredItem (line 203) | type ColoredItem = Item & { color: Color }
type ShapedItem (line 204) | type ShapedItem = Note
type Item (line 205) | type Item = TextItem | Image | Video
type ItemLocks (line 206) | type ItemLocks = Record<Id, Id>
type RecentBoardAttributes (line 208) | type RecentBoardAttributes = { id: Id; name: string }
type RecentBoard (line 209) | type RecentBoard = RecentBoardAttributes & { opened: ISOTimeStamp; userE...
type BoardEvent (line 211) | type BoardEvent = { boardId: Id }
type UIEvent (line 212) | type UIEvent = BoardItemEvent | ClientToServerRequest | LocalUIEvent
type LocalUIEvent (line 213) | type LocalUIEvent = Undo | Redo | SetLocalBoard | GoOnline | BoardLogged...
type EventFromServer (line 214) | type EventFromServer = BoardHistoryEntry | BoardStateSyncEvent | LoginRe...
type ServerConfig (line 215) | type ServerConfig = {
type Serial (line 221) | type Serial = number
type AppEvent (line 222) | type AppEvent =
type EventWrapper (line 230) | type EventWrapper = {
type PersistableBoardItemEvent (line 234) | type PersistableBoardItemEvent =
type BoardInit (line 248) | type BoardInit = InitBoardNew | InitBoardDiff
type TransientBoardItemEvent (line 249) | type TransientBoardItemEvent = LockItem | UnlockItem
type BoardItemEvent (line 250) | type BoardItemEvent = PersistableBoardItemEvent | TransientBoardItemEvent
type BoardStateSyncEvent (line 251) | type BoardStateSyncEvent =
type ClientToServerRequest (line 267) | type ClientToServerRequest =
type LoginResponse (line 283) | type LoginResponse =
type AddConnection (line 286) | type AddConnection = { action: "connection.add"; boardId: Id; connection...
type ModifyConnection (line 287) | type ModifyConnection = { action: "connection.modify"; boardId: Id; conn...
type DeleteConnection (line 288) | type DeleteConnection = { action: "connection.delete"; boardId: Id; conn...
type UserLoggedIn (line 289) | type UserLoggedIn = {
type AuthJWTLogin (line 295) | type AuthJWTLogin = {
type AuthLogout (line 299) | type AuthLogout = { action: "auth.logout" }
type Ping (line 300) | type Ping = { action: "ping" }
type AddItem (line 301) | type AddItem = { action: "item.add"; boardId: Id; items: Item[]; connect...
type UpdateItem (line 302) | type UpdateItem = { action: "item.update"; boardId: Id; items: ItemUpdat...
type Update (line 303) | type Update<T> = Partial<T> & { id: Id }
type ItemUpdate (line 304) | type ItemUpdate<I extends Item = Item> = Update<I>
type ConnectionUpdate (line 305) | type ConnectionUpdate = Update<Connection>
type MoveItem (line 306) | type MoveItem = {
type IncreaseItemFont (line 312) | type IncreaseItemFont = { action: "item.font.increase"; boardId: Id; ite...
type DecreaseItemFont (line 313) | type DecreaseItemFont = { action: "item.font.decrease"; boardId: Id; ite...
type BringItemToFront (line 314) | type BringItemToFront = { action: "item.front"; boardId: Id; itemIds: Id...
type DeleteItem (line 315) | type DeleteItem = { action: "item.delete"; boardId: Id; itemIds: Id[]; c...
type BootstrapBoard (line 316) | type BootstrapBoard = { action: "item.bootstrap"; boardId: Id } & BoardC...
type LockItem (line 317) | type LockItem = { action: "item.lock"; boardId: Id; itemId: Id }
type UnlockItem (line 318) | type UnlockItem = { action: "item.unlock"; boardId: Id; itemId: Id }
type GotBoardLocks (line 319) | type GotBoardLocks = { action: "board.locks"; boardId: Id; locks: ItemLo...
type AddBoard (line 320) | type AddBoard = { action: "board.add"; payload: Board | BoardStub }
type AckAddBoard (line 321) | type AckAddBoard = { action: "board.add.ack"; boardId: Id }
type JoinBoard (line 322) | type JoinBoard = { action: "board.join"; boardId: Id; initAtSerial?: Ser...
type BringAllToMe (line 323) | type BringAllToMe = { action: "user.bringAllToMe"; boardId: Id; sessionI...
type AssociateBoard (line 324) | type AssociateBoard = { action: "board.associate"; boardId: Id; lastOpen...
type DissociateBoard (line 325) | type DissociateBoard = { action: "board.dissociate"; boardId: Id }
type SetBoardAccessPolicy (line 326) | type SetBoardAccessPolicy = {
type AckJoinBoard (line 331) | type AckJoinBoard = { action: "board.join.ack"; boardId: Id } & UserSess...
type DeniedJoinBoard (line 332) | type DeniedJoinBoard =
type RecentBoardsFromServer (line 344) | type RecentBoardsFromServer = { action: "user.boards"; email: string; bo...
type Ack (line 345) | type Ack = { action: "ack"; ackId: string; serials: Record<Id, Serial> }
type ActionApplyFailed (line 346) | type ActionApplyFailed = { action: "board.action.apply.failed" }
type JoinedBoard (line 347) | type JoinedBoard = { action: "board.joined"; boardId: Id } & UserSession...
type LeftBoard (line 348) | type LeftBoard = { action: "board.left"; boardId: Id; sessionId: Id }
type UserInfoUpdate (line 349) | type UserInfoUpdate = { action: "userinfo.set" } & UserSessionInfo
type InitBoardNew (line 350) | type InitBoardNew = { action: "board.init"; board: Board; accessLevel: A...
type InitBoardDiff (line 351) | type InitBoardDiff = {
type RenameBoard (line 360) | type RenameBoard = { action: "board.rename"; boardId: Id; name: string }
type CursorMove (line 361) | type CursorMove = { action: "cursor.move"; position: CursorPosition; boa...
type SetNickname (line 362) | type SetNickname = { action: "nickname.set"; nickname: string }
type AssetPutUrlRequest (line 363) | type AssetPutUrlRequest = { action: "asset.put.request"; assetId: string }
type AssetPutUrlResponse (line 364) | type AssetPutUrlResponse = { action: "asset.put.response"; assetId: stri...
type Undo (line 365) | type Undo = { action: "ui.undo" }
type Redo (line 366) | type Redo = { action: "ui.redo" }
type TextFormat (line 367) | type TextFormat = { action: "ui.text.format"; itemIds: Id[]; format: "bo...
type SetLocalBoard (line 369) | type SetLocalBoard = {
type BoardLoggedOut (line 374) | type BoardLoggedOut = { action: "ui.board.logged.out"; boardId: Id }
type GoOffline (line 375) | type GoOffline = { action: "ui.offline" }
type GoOnline (line 376) | type GoOnline = { action: "ui.online" }
constant CURSOR_POSITIONS_ACTION_TYPE (line 378) | const CURSOR_POSITIONS_ACTION_TYPE = "c" as const
type CursorPositions (line 379) | type CursorPositions = { action: typeof CURSOR_POSITIONS_ACTION_TYPE; p:...
function newBoard (line 394) | function newBoard(name: string, crdt?: CrdtMode, accessPolicy?: BoardAcc...
function newNote (line 398) | function newNote(
function newSimilarNote (line 411) | function newSimilarNote(note: Note) {
function newText (line 415) | function newText(
function newContainer (line 439) | function newContainer(
function newImage (line 462) | function newImage(
function newVideo (line 473) | function newVideo(
function isSameUser (line 497) | function isSameUser(a: EventUserInfo, b: EventUserInfo) {
function isColoredItem (line 501) | function isColoredItem(i: Item): i is ColoredItem {
function isShapedItem (line 505) | function isShapedItem(i: Item): i is ShapedItem {
function isTextItem (line 509) | function isTextItem(i: Item): i is TextItem {
function isNote (line 513) | function isNote(i: Item): i is Note {
function isContainer (line 517) | function isContainer(i: Item): i is Container {
function isText (line 521) | function isText(i: Item): i is Text {
function isItem (line 525) | function isItem(i: Item | Point | Connection): i is Item {
function getItemText (line 529) | function getItemText(i: Item) {
function getItemBackground (line 534) | function getItemBackground(i: Item) {
function getItemShape (line 541) | function getItemShape(i: Item) {
type NamespacedEvent (line 545) | type NamespacedEvent<Namespace extends string, T = AppEvent> = T extends...
function actionNamespaceIs (line 549) | function actionNamespaceIs<Namespace extends string>(
function getItemIds (line 556) | function getItemIds(e: BoardHistoryEntry | PersistableBoardItemEvent): I...
function findItemIdsRecursively (line 603) | function findItemIdsRecursively(ids: Id[], board: Board): Set<Id> {
function findItemsRecursively (line 613) | function findItemsRecursively(ids: Id[], board: Board): Item[] {
function isBoardEmpty (line 636) | function isBoardEmpty(board: Board) {
function getBoardAttributes (line 640) | function getBoardAttributes(board: Board, userInfo?: EventUserInfo): Boa...
constant BOARD_ITEM_BORDER_MARGIN (line 655) | const BOARD_ITEM_BORDER_MARGIN = 0.5
function checkBoardAccess (line 657) | function checkBoardAccess(accessPolicy: BoardAccessPolicy | undefined, u...
function combineAccessLevels (line 683) | function combineAccessLevels(a: AccessLevel, b: AccessLevel): AccessLevel {
function canRead (line 690) | function canRead(a: AccessLevel) {
function canWrite (line 694) | function canWrite(a: AccessLevel) {
type Align (line 698) | type Align = "TL" | "TC" | "TR" | "ML" | "MC" | "MR" | "BL" | "BC" | "BR"
function getAlign (line 700) | function getAlign(item: TextItem) {
type HorizontalAlign (line 704) | type HorizontalAlign = "left" | "center" | "right"
function getHorizontalAlign (line 706) | function getHorizontalAlign(align: Align): HorizontalAlign {
type VerticalAlign (line 725) | type VerticalAlign = "top" | "middle" | "bottom"
function getVerticalAlign (line 727) | function getVerticalAlign(align: Align): VerticalAlign {
function setHorizontalAlign (line 746) | function setHorizontalAlign<I extends TextItem>(item: I, a: HorizontalAl...
function setVerticalAlign (line 752) | function setVerticalAlign<I extends TextItem>(item: I, a: VerticalAlign)...
FILE: common/src/geometry.ts
type Coordinates (line 3) | type Coordinates = { x: number; y: number }
type Dimensions (line 4) | type Dimensions = { width: number; height: number }
type Rect (line 5) | type Rect = { x: number; y: number; width: number; height: number }
constant ZERO_RECT (line 6) | const ZERO_RECT = { x: 0, y: 0, height: 0, width: 0 }
function add (line 7) | function add(a: Coordinates, b: Coordinates) {
function subtract (line 11) | function subtract(a: Coordinates, b: Coordinates) {
function negate (line 15) | function negate(a: Coordinates) {
function multiply (line 19) | function multiply(a: Coordinates, factor: number) {
function overlaps (line 23) | function overlaps(a: Rect, b: Rect) {
function equalRect (line 31) | function equalRect(a: Rect, b: Rect) {
function distance (line 35) | function distance(a: Coordinates, b: Coordinates) {
function containedBy (line 41) | function containedBy(a: Rect | Point, b: Rect) {
function rectFromPoints (line 49) | function rectFromPoints(a: Coordinates, b: Coordinates) {
function isRect (line 59) | function isRect(i: Point | Rect): i is Rect {
function centerPoint (line 63) | function centerPoint(i: Point | Rect) {
FILE: common/src/migration.ts
function mkBootStrapEvent (line 6) | function mkBootStrapEvent(boardId: Id, snapshot: Board, serial: Serial =...
function migrateBoard (line 18) | function migrateBoard(origBoard: Board) {
function migrateConnection (line 56) | function migrateConnection(c: Connection): Connection {
function migrateItem (line 66) | function migrateItem(item: Item, migratedItems: Item[], boardItems: Reco...
function migrateEvent (line 99) | function migrateEvent(event: BoardHistoryEntry): BoardHistoryEntry {
FILE: common/src/sets.ts
function toggleInSet (line 1) | function toggleInSet<T>(item: T, set: Set<T>) {
function difference (line 8) | function difference<A>(setA: Set<A>, setB: Set<A>) {
FILE: common/src/sleep.ts
function sleep (line 1) | function sleep(ms: number): Promise<void> {
FILE: common/src/vector2.ts
type Vector2 (line 1) | type Vector2 = { x: number; y: number }
function Vector2 (line 3) | function Vector2(x: number, y: number) {
function getAngleRad (line 7) | function getAngleRad(v: Vector2) {
function getAngleDeg (line 12) | function getAngleDeg(v: Vector2) {
function getLength (line 16) | function getLength(v: Vector2) {
function withLength (line 20) | function withLength(v: Vector2, newLength: number) {
function multiply (line 24) | function multiply(v: Vector2, multiplier: number) {
function add (line 28) | function add(v: Vector2, other: Vector2) {
function rotateRad (line 32) | function rotateRad(v: Vector2, radians: number) {
function rotateDeg (line 40) | function rotateDeg(v: Vector2, degrees: number) {
function degToRad (line 44) | function degToRad(degrees: number) {
function radToDeg (line 48) | function radToDeg(rad: number) {
FILE: frontend/esbuild.js
method setup (line 18) | setup(build) {
method setup (line 34) | setup(build) {
constant CWD (line 56) | const CWD = process.cwd()
constant DIST_FOLDER (line 57) | const DIST_FOLDER = path.resolve(CWD, "dist")
function build (line 61) | async function build() {
FILE: frontend/src/board-navigation.ts
constant BOARD_PATH (line 7) | const BOARD_PATH = "/b/:boardId"
constant ROOT_PATH (line 8) | const ROOT_PATH = "/"
type Routes (line 15) | type Routes = typeof Routes
function BoardNavigation (line 17) | function BoardNavigation() {
function createBoardAndNavigate (line 36) | function createBoardAndNavigate(
FILE: frontend/src/board/BoardView.tsx
function onURL (line 137) | function onURL(assetId: string, url: string) {
function onClick (line 198) | function onClick(e: JSX.UIEvent) {
function onAdd (line 223) | function onAdd(item: Item) {
function renderSelectionBorder (line 362) | function renderSelectionBorder(id: string, item: L.Property<Item>) {
function renderItem (line 366) | function renderItem(id: string, item: L.Property<Item>) {
FILE: frontend/src/board/CollaborativeTextView.tsx
type CollaborativeTextViewProps (line 28) | interface CollaborativeTextViewProps {
function CollaborativeTextView (line 39) | function CollaborativeTextView({
FILE: frontend/src/board/ConnectionsView.tsx
function determineAttachmenLocation (line 36) | function determineAttachmenLocation(
type ConnectionNodeProps (line 144) | type ConnectionNodeProps = {
function ConnectionNode (line 151) | function ConnectionNode(key: string, cNode: L.Property<ConnectionNodePro...
function quadraticCurveSVGPath (line 230) | function quadraticCurveSVGPath(from: Point, to: Point, controlPoints: Po...
function getControlPoint (line 248) | function getControlPoint(from: Point, to: Point, controlPoints: Point[]) {
function bezierCurveFromPoints (line 254) | function bezierCurveFromPoints(from: Point, middle: Point, to: Point): a...
FILE: frontend/src/board/DragBorder.tsx
type Position (line 10) | type Position = "left" | "right" | "top" | "bottom"
function DragHandle (line 38) | function DragHandle({ position }: { position: Position }) {
FILE: frontend/src/board/ItemView.tsx
function itemPadding (line 85) | function itemPadding(i: Item) {
function getJustifyContent (line 188) | function getJustifyContent(item: Item) {
function getAlignItems (line 202) | function getAlignItems(item: Item) {
function getTextAlign (line 216) | function getTextAlign(item: Item) {
FILE: frontend/src/board/SaveAsTemplate.tsx
function handleLocalTemplateSave (line 8) | function handleLocalTemplateSave() {
FILE: frontend/src/board/SelectionBorder.tsx
type Horizontal (line 10) | type Horizontal = "left" | "right"
type Vertical (line 11) | type Vertical = "top" | "bottom"
function DragCorner (line 53) | function DragCorner({ vertical, horizontal }: { vertical: Vertical; hori...
FILE: frontend/src/board/TextView.tsx
type TextViewProps (line 13) | interface TextViewProps {
function TextView (line 26) | function TextView({
FILE: frontend/src/board/autoFontSize.ts
type AutoFontSizeOptions (line 8) | type AutoFontSizeOptions = {
function getElementFont (line 17) | function getElementFont(e: HTMLElement | null) {
function autoFontSize (line 32) | function autoFontSize(
function getTextDimensions (line 153) | function getTextDimensions(text: string, font: string): Dimensions {
FILE: frontend/src/board/board-coordinates.ts
type PageCoordinates (line 13) | type PageCoordinates = Coordinates
type BoardCoordinates (line 15) | type BoardCoordinates = Coordinates
type BoardCoordinateHelper (line 17) | type BoardCoordinateHelper = ReturnType<typeof boardCoordinateHelper>
function boardCoordinateHelper (line 19) | function boardCoordinateHelper(
FILE: frontend/src/board/board-drag.ts
type DragAction (line 15) | type DragAction =
function boardDragHandler (line 21) | function boardDragHandler({
FILE: frontend/src/board/board-focus.ts
type BoardFocus (line 5) | type BoardFocus =
function getSelectedItemIds (line 13) | function getSelectedItemIds(f: BoardFocus): Set<Id> {
function getSelectedConnectionIds (line 27) | function getSelectedConnectionIds(f: BoardFocus): Set<Id> {
function removeFromSelection (line 55) | function removeFromSelection(
function removeNonExistingFromSelection (line 78) | function removeNonExistingFromSelection(selection: BoardFocus, board: Bo...
FILE: frontend/src/board/board-permissions.ts
type BoardPermission (line 14) | type BoardPermission = (item: Item | Connection) => boolean
FILE: frontend/src/board/board-scroll-and-zoom.ts
type BoardZoom (line 11) | type BoardZoom = { zoom: number; quickZoom: number }
type ZoomAdjustMode (line 12) | type ZoomAdjustMode = "preserveCursor" | "preserveCenter"
function nonNull (line 14) | function nonNull<A>(x: A | null | undefined): x is A {
type ZoomAndScrollControls (line 18) | type ZoomAndScrollControls = ReturnType<typeof boardScrollAndZoomHandler>
function boardScrollAndZoomHandler (line 20) | function boardScrollAndZoomHandler(
FILE: frontend/src/board/boardContentArea.ts
function combineRects (line 5) | function combineRects(r1: Rect, r2: Rect): Rect {
function itemToRect (line 13) | function itemToRect(item: Rect): Rect {
function addMargin (line 17) | function addMargin(rect: Rect, margin: number): Rect {
function setMinimumSizeKeepingCenter (line 26) | function setMinimumSizeKeepingCenter(rect: Rect, minimumSize: { width: n...
function clampIntoKeepingSize (line 38) | function clampIntoKeepingSize(rect: Rect, bounds: Rect) {
function growToProportions (line 48) | function growToProportions(rect: Rect, proportions: { width: number; hei...
function boardContentArea (line 65) | function boardContentArea(b: Board, viewPortDimensions: { width: number;...
FILE: frontend/src/board/contextmenu/ContextMenuView.tsx
type SubmenuProps (line 20) | type SubmenuProps = {
type SubMenuCreator (line 27) | type SubMenuCreator = (props: SubmenuProps) => HarmajaOutput
type ItemsAndConnections (line 39) | type ItemsAndConnections = { items: Item[]; connections: Connection[] }
type Bounds (line 40) | type Bounds = { minX: number; maxX: number; minY: number; maxY: number }...
FILE: frontend/src/board/contextmenu/alignments.tsx
function alignmentsMenu (line 22) | function alignmentsMenu(axis: Axis, props: SubmenuProps) {
type Axis (line 47) | type Axis = "x" | "y"
type GetCoordinate (line 48) | type GetCoordinate = (
function getItemSize (line 59) | function getItemSize(item: Item, axis: Axis) {
function moveFocusedItems (line 63) | function moveFocusedItems(
function alignmentsSubMenu (line 99) | function alignmentsSubMenu(axis: Axis, props: SubmenuProps) {
FILE: frontend/src/board/contextmenu/areaTiling.tsx
function areaTilingMenu (line 10) | function areaTilingMenu({ board, focusedItems, dispatch }: SubmenuProps) {
FILE: frontend/src/board/contextmenu/colors.tsx
function colorsSubMenu (line 7) | function colorsSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {
function itemColorOrDefault (line 43) | function itemColorOrDefault(items: Item[]) {
FILE: frontend/src/board/contextmenu/colorsAndShapes.tsx
function createSubMenu (line 11) | function createSubMenu(props: SubmenuProps) {
function colorsAndShapesMenu (line 20) | function colorsAndShapesMenu(props: SubmenuProps) {
FILE: frontend/src/board/contextmenu/connection-ends.tsx
function nextStyle (line 19) | function nextStyle(style: ConnectionEndStyle) {
function connectionEndsMenu (line 24) | function connectionEndsMenu({ board, focusedItems, dispatch }: SubmenuPr...
FILE: frontend/src/board/contextmenu/fontSizes.tsx
type MenuIconProps (line 9) | type MenuIconProps = {
function fontSizesMenu (line 28) | function fontSizesMenu({ board, focusedItems, dispatch }: SubmenuProps) {
FILE: frontend/src/board/contextmenu/hideContents.tsx
function hideContentsMenu (line 9) | function hideContentsMenu({ board, focusedItems, dispatch }: SubmenuProp...
FILE: frontend/src/board/contextmenu/lock.tsx
function lockMenu (line 8) | function lockMenu({ board, focusedItems, dispatch }: SubmenuProps) {
FILE: frontend/src/board/contextmenu/shapes.tsx
type ShapeIcon (line 16) | type ShapeIcon = (c: Color, f?: Color) => HarmajaOutput
type ShapeIconAndId (line 17) | type ShapeIconAndId = { id: NoteShape; svg: ShapeIcon }
function getShapeIcon (line 20) | function getShapeIcon(item: Item): ShapeIcon {
function shapesSubMenu (line 24) | function shapesSubMenu({ board, focusedItems, dispatch }: SubmenuProps) {
FILE: frontend/src/board/contextmenu/textAlignments.tsx
function textAlignmentsMenu (line 30) | function textAlignmentsMenu({ board, focusedItems, dispatch }: SubmenuPr...
function getIfSame (line 101) | function getIfSame<I, P>(items: I[], get: (item: I) => P | null, default...
FILE: frontend/src/board/contextmenu/textFormats.tsx
function textFormatsMenu (line 8) | function textFormatsMenu({ board, focusedItems, dispatch }: SubmenuProps) {
FILE: frontend/src/board/contrasting-color.ts
type RGB (line 1) | interface RGB {
function rgbToYIQ (line 6) | function rgbToYIQ({ r, g, b }: RGB): number {
function hexToRgb (line 9) | function hexToRgb(hex: string): RGB | undefined {
function contrastingColor (line 25) | function contrastingColor(colorNameOrHex: string | undefined, threshold:...
function colorNameToHex (line 184) | function colorNameToHex(c: string) {
FILE: frontend/src/board/double-click.ts
function installDoubleClickHandler (line 5) | function installDoubleClickHandler(action: (e: JSX.UIEvent) => void) {
function preventDoubleClick (line 32) | function preventDoubleClick(e: JSX.TouchEvent) {
FILE: frontend/src/board/header/BoardViewHeader.tsx
function BoardViewHeader (line 17) | function BoardViewHeader({
FILE: frontend/src/board/header/OtherUsersView.tsx
type OtherUsersViewProps (line 9) | type OtherUsersViewProps = {
FILE: frontend/src/board/header/SharingModalDialog.tsx
function copyToClipboard (line 23) | function copyToClipboard() {
function saveChanges (line 28) | function saveChanges() {
FILE: frontend/src/board/header/UserInfoView.tsx
function dismiss (line 36) | function dismiss() {
function showDialog (line 39) | function showDialog() {
FILE: frontend/src/board/image-upload.ts
function imageDropHandler (line 7) | function imageDropHandler(
type ImageUploadFunction (line 52) | type ImageUploadFunction = (file: File) => Promise<void>
function imageUploadHandler (line 53) | function imageUploadHandler(
type ImageInfo (line 78) | type ImageInfo = { type: "image"; width: number; height: number }
function imageDimensions (line 80) | function imageDimensions(file: File): Promise<ImageInfo | null> {
FILE: frontend/src/board/item-connect.ts
constant DND_GHOST_HIDING_IMAGE (line 27) | const DND_GHOST_HIDING_IMAGE = new Image()
function startConnecting (line 34) | function startConnecting(
type ConnectionHandler (line 71) | type ConnectionHandler = ReturnType<typeof newConnectionCreator>
function newConnectionCreator (line 73) | function newConnectionCreator(
function shouldPreventAttach (line 164) | function shouldPreventAttach(e: DragEvent) {
function existingConnectionHandler (line 168) | function existingConnectionHandler(
function getFindTargetOptions (line 229) | function getFindTargetOptions(action: "line" | "connect", preventAttach ...
type FindTargetOptions (line 236) | type FindTargetOptions = { allowConnect: boolean; allowSnap: boolean }
function findTarget (line 237) | function findTarget(
function isConnected (line 278) | function isConnected(b: Board, x: Item, y: Item, connectionToIgnore: Con...
function isConnectionRelated (line 282) | function isConnectionRelated(i: Item, c: Connection) {
function isEndPointRelated (line 286) | function isEndPointRelated(i: Item, c: ConnectionEndPoint) {
function isConnectionAttachmentPoint (line 290) | function isConnectionAttachmentPoint(point: Point, item: Item) {
FILE: frontend/src/board/item-create.ts
function itemCreateHandler (line 7) | function itemCreateHandler(
FILE: frontend/src/board/item-cut-copy-paste.ts
constant CLIPBOARD_EVENTS (line 28) | const CLIPBOARD_EVENTS = ["cut", "copy", "paste"] as const
type ItemsAndConnections (line 30) | type ItemsAndConnections = {
function augmentWithCRDT (line 35) | function augmentWithCRDT(
function findSelectedItemsAndConnections (line 46) | function findSelectedItemsAndConnections(currentFocus: BoardFocus, curre...
function detachEndPointIfItemNotFound (line 74) | function detachEndPointIfItemNotFound(ep: ConnectionEndPoint, itemIds: S...
function connectedIds (line 82) | function connectedIds(connection: Connection) {
function makeCopies (line 87) | function makeCopies(
function cutCopyPasteHandler (line 146) | function cutCopyPasteHandler(
FILE: frontend/src/board/item-delete.ts
function itemDeleteHandler (line 7) | function itemDeleteHandler(boardId: Id, dispatch: Dispatch, focus: L.Pro...
function dispatchDeletion (line 14) | function dispatchDeletion(boardId: Id, f: BoardFocus, dispatch: Dispatch) {
FILE: frontend/src/board/item-drag.ts
constant DND_GHOST_HIDING_IMAGE (line 8) | const DND_GHOST_HIDING_IMAGE = new Image()
function onBoardItemDrag (line 13) | function onBoardItemDrag(
FILE: frontend/src/board/item-dragmove.ts
function itemDragToMove (line 12) | function itemDragToMove(
FILE: frontend/src/board/item-duplicate.ts
function itemDuplicateHandler (line 10) | function itemDuplicateHandler(
function dispatchDuplication (line 21) | function dispatchDuplication(
FILE: frontend/src/board/item-hide-contents.ts
function itemHideContentsHandler (line 8) | function itemHideContentsHandler(board: L.Property<Board>, focus: L.Prop...
function hasContentHidden (line 14) | function hasContentHidden(items: Item[]) {
function toggleContentsHidden (line 18) | function toggleContentsHidden(items: Item[], board: Board, dispatch: Dis...
function findContainers (line 31) | function findContainers(items: Item[], board: Board): Item[] {
FILE: frontend/src/board/item-move-with-arrow-keys.ts
function updatePosition (line 10) | function updatePosition<T extends Rect>(board: Board, item: T, dx: numbe...
function moveItem (line 19) | function moveItem<T extends Rect>(board: Board, item: T, key: string, sh...
function itemMoveWithArrowKeysHandler (line 34) | function itemMoveWithArrowKeysHandler(board: L.Property<Board>, dispatch...
FILE: frontend/src/board/item-organizer.ts
constant ITEM_MARGIN (line 6) | const ITEM_MARGIN = 1
constant CONTAINER_MARGIN (line 7) | const CONTAINER_MARGIN = 1
function contentRect (line 10) | function contentRect(cont: Container): G.Rect {
function packableItems (line 25) | function packableItems(cont: Container, board: Board): Item[] {
function organizeItems (line 34) | function organizeItems(itemsToPack: Item[], itemsToAvoid: Item[], rect: ...
function placeItem (line 54) | function placeItem(
function marginRect (line 86) | function marginRect(margin: number, r: G.Rect): G.Rect {
FILE: frontend/src/board/item-packer.ts
type PackItemsResult (line 8) | type PackItemsResult =
constant PACK_BINARY_SEARCH_DEFAULT (line 18) | const PACK_BINARY_SEARCH_DEFAULT: {
function packItems (line 35) | function packItems(targetRect: Rect, items: Item[], binarySearch = PACK_...
FILE: frontend/src/board/item-select-all.ts
function itemSelectAllHandler (line 6) | function itemSelectAllHandler(board: L.Property<Board>, focus: L.Atom<Bo...
FILE: frontend/src/board/item-selection.ts
function itemSelectionHandler (line 11) | function itemSelectionHandler(
FILE: frontend/src/board/item-setcontainer.ts
function maybeChangeContainerForItem (line 5) | function maybeChangeContainerForItem(item: Item, items: Record<Id, Item>...
function maybeChangeContainerForConnection (line 13) | function maybeChangeContainerForConnection(connection: Connection, items...
function withCurrentContainer (line 21) | function withCurrentContainer(item: Item, b: Board): Item {
FILE: frontend/src/board/item-undo-redo.ts
function itemUndoHandler (line 4) | function itemUndoHandler(dispatch: Dispatch) {
FILE: frontend/src/board/keyboard-shortcuts.ts
function installKeyboardShortcut (line 4) | function installKeyboardShortcut(
FILE: frontend/src/board/local-storage-atom.ts
function localStorageAtom (line 3) | function localStorageAtom<T>(key: string, defaultValue: T) {
FILE: frontend/src/board/quillClickableLink.ts
class ClickableLink (line 4) | class ClickableLink extends Link {
method create (line 5) | static create(href: any) {
FILE: frontend/src/board/quillPasteLinkOverText.ts
class PasteLinkOverText (line 4) | class PasteLinkOverText {
method constructor (line 5) | constructor(quill: Quill) {
FILE: frontend/src/board/synchronize-focus-with-server.ts
function synchronizeFocusWithServer (line 29) | function synchronizeFocusWithServer(
FILE: frontend/src/board/tool-selection.ts
type Tool (line 4) | type Tool = "pan" | "select" | "connect" | "note" | "container" | "text"...
type ControlSettings (line 5) | type ControlSettings = {
type ToolController (line 10) | type ToolController = ReturnType<typeof ToolController>
function ToolController (line 12) | function ToolController() {
FILE: frontend/src/board/toolbars/BackToAllBoardsLink.tsx
function BackToAllBoardsLink (line 5) | function BackToAllBoardsLink() {
FILE: frontend/src/board/toolbars/MainToolBar.tsx
type ToolbarPosition (line 42) | type ToolbarPosition = { x?: number; y?: number; orientation: "vertical"...
type UndoProps (line 123) | type UndoProps = {
type DeleteProps (line 151) | type DeleteProps = {
type DuplicateProps (line 175) | type DuplicateProps = {
FILE: frontend/src/board/toolbars/MiniMapView.tsx
function onDragEnd (line 37) | function onDragEnd(e: JSX.DragEvent) {
function onDragStart (line 40) | function onDragStart(e: JSX.DragEvent) {
function onDragOver (line 45) | function onDragOver(e: JSX.DragEvent) {
function onClick (line 55) | function onClick(e: JSX.MouseEvent | Touch) {
function onTouchStart (line 67) | function onTouchStart(e: JSX.TouchEvent) {
function onTouchMove (line 71) | function onTouchMove(e: JSX.TouchEvent) {
function onTouchEnd (line 75) | function onTouchEnd(e: JSX.TouchEvent) {
function renderItem (line 110) | function renderItem(id: string, item: L.Property<Item>) {
FILE: frontend/src/board/toolbars/PaletteView.tsx
function lightenDarkenColor (line 67) | function lightenDarkenColor(col: string, amt: number) {
FILE: frontend/src/board/toolbars/UndoRedo.tsx
function UndoRedo (line 5) | function UndoRedo({ dispatch, boardStore }: { dispatch: Dispatch; boardS...
FILE: frontend/src/board/toolbars/ZoomControls.tsx
function ZoomControls (line 5) | function ZoomControls({
FILE: frontend/src/board/touchScreen.ts
constant IS_TOUCHSCREEN (line 1) | const IS_TOUCHSCREEN = "ontouchstart" in window
function getSingleTouch (line 3) | function getSingleTouch(e: TouchEvent | JSX.TouchEvent) {
function isSingleTouch (line 8) | function isSingleTouch(e: TouchEvent | JSX.TouchEvent) {
function onSingleTouch (line 12) | function onSingleTouch(e: TouchEvent | JSX.TouchEvent, callback: (touch:...
FILE: frontend/src/board/zIndices.ts
constant Z_CONTAINERS_UP_TO (line 3) | const Z_CONTAINERS_UP_TO = 1000000
constant Z_CONNECTIONS (line 4) | const Z_CONNECTIONS = 200000000
constant Z_ITEMS_FROM (line 5) | const Z_ITEMS_FROM = Z_CONTAINERS_UP_TO + 10
function itemZIndex (line 7) | function itemZIndex(item: Item) {
FILE: frontend/src/board/zoom-shortcuts.ts
function installZoomKeyboardShortcuts (line 5) | function installZoomKeyboardShortcuts({ resetZoom, increaseZoom, decreas...
FILE: frontend/src/components/BoardAccessPolicyEditor.tsx
type BoardAccessPolicyEditorProps (line 7) | type BoardAccessPolicyEditorProps = {
function parseAccessListEntry (line 59) | function parseAccessListEntry(input: string): AccessListEntry | null {
function addToAllowListIfValid (line 75) | function addToAllowListIfValid(input: string) {
FILE: frontend/src/components/BoardCrdtModeSelector.tsx
type BoardCrdtModeSelectorProps (line 5) | type BoardCrdtModeSelectorProps = {
FILE: frontend/src/components/EditableSpan.tsx
type EditableSpanProps (line 6) | type EditableSpanProps = {
function clearSelection (line 14) | function clearSelection() {
FILE: frontend/src/components/HTMLEditableSpan.tsx
type EditableSpanProps (line 7) | type EditableSpanProps = {
function clearSelection (line 14) | function clearSelection() {
FILE: frontend/src/components/ModalContainer.tsx
function ModalContainer (line 4) | function ModalContainer({ content }: { content: L.Atom<any> }) {
FILE: frontend/src/components/onClickOutside.tsx
function onClickOutside (line 4) | function onClickOutside(elem: L.Property<HTMLElement | null>, handler: (...
FILE: frontend/src/components/sanitizeHTML.ts
function isURL (line 37) | function isURL(str: string) {
constant MAX_LINK_LENGTH (line 44) | const MAX_LINK_LENGTH = 30
function createLinkHTML (line 46) | function createLinkHTML(url: string, text?: string) {
function createAnchorElement (line 50) | function createAnchorElement(url: string, text?: string) {
function linkify (line 60) | function linkify(htmlText: string) {
function sanitizeHTML (line 76) | function sanitizeHTML(html: string, shouldLinkify?: boolean) {
function toPlainText (line 85) | function toPlainText(html: string) {
FILE: frontend/src/dashboard/DashboardView.tsx
function createTutorial (line 227) | function createTutorial() {
function onSubmit (line 330) | function onSubmit(e: JSX.FormEvent) {
FILE: frontend/src/google-auth.ts
function signIn (line 3) | function signIn() {
function signOut (line 7) | async function signOut() {
function getReturnPath (line 12) | function getReturnPath() {
FILE: frontend/src/store/asset-store.ts
type AssetId (line 6) | type AssetId = string
type AssetURL (line 7) | type AssetURL = string
function assetStore (line 9) | function assetStore(
function isAssetPut (line 111) | function isAssetPut(e: AppEvent): e is AssetPutUrlResponse {
function getAssetPutResponse (line 115) | function getAssetPutResponse(assetId: string, events: L.EventStream<AppE...
type AssetStore (line 127) | type AssetStore = ReturnType<typeof assetStore>
FILE: frontend/src/store/board-local-store.ts
type LocalStorageBoard (line 6) | type LocalStorageBoard = {
constant BOARD_STORAGE_KEY_PREFIX (line 11) | const BOARD_STORAGE_KEY_PREFIX = "board_"
function getInitialBoardState (line 15) | async function getInitialBoardState(boardId: Id) {
function getStoredState (line 23) | async function getStoredState(localStorageKey: string): Promise<LocalSto...
function getStorageKey (line 40) | function getStorageKey(boardId: string) {
function storeBoardState (line 46) | async function storeBoardState(newState: LocalStorageBoard): Promise<voi...
function clearBoardState (line 63) | async function clearBoardState(boardId: Id) {
function clearStateByKey (line 67) | async function clearStateByKey(localStorageKey: string) {
function clearAllPrivateBoards (line 75) | async function clearAllPrivateBoards(): Promise<void> {
type BoardLocalStore (line 90) | type BoardLocalStore = {
FILE: frontend/src/store/board-store.test.ts
function waitForBackgroundJobs (line 264) | async function waitForBackgroundJobs() {
function initBoardStore (line 268) | async function initBoardStore({
FILE: frontend/src/store/board-store.ts
type Dispatch (line 42) | type Dispatch = (e: UIEvent) => void
type BoardStore (line 43) | type BoardStore = ReturnType<typeof BoardStore>
type BoardAccessStatus (line 44) | type BoardAccessStatus =
type BoardState (line 54) | type BoardState = {
function emptyBoard (line 65) | function emptyBoard(boardId: Id) {
function BoardStore (line 69) | function BoardStore(
function sessionState2UserInfo (line 587) | function sessionState2UserInfo(state: UserSessionState): SessionUserInfo {
FILE: frontend/src/store/crdt-store.ts
type BoardCRDT (line 10) | type BoardCRDT = ReturnType<typeof BoardCRDT>
type WebSocketPolyfill (line 11) | type WebSocketPolyfill =
function BoardCRDT (line 22) | function BoardCRDT(
type CRDTStore (line 85) | type CRDTStore = ReturnType<typeof CRDTStore>
function CRDTStore (line 87) | function CRDTStore(
FILE: frontend/src/store/cursors-store.ts
function CursorsStore (line 7) | function CursorsStore(connection: ServerConnection, sessionStore: UserSe...
type CursorsStore (line 40) | type CursorsStore = ReturnType<typeof CursorsStore>
function isCursors (line 42) | function isCursors(e: AppEvent): e is CursorPositions {
FILE: frontend/src/store/recent-boards.ts
function RecentBoards (line 6) | function RecentBoards(connection: ServerConnection, sessionStore: UserSe...
type RecentBoards (line 50) | type RecentBoards = ReturnType<typeof RecentBoards>
FILE: frontend/src/store/server-connection.ts
type Dispatch (line 7) | type Dispatch = (e: UIEvent) => void
constant SERVER_EVENTS_BUFFERING_MILLIS (line 9) | const SERVER_EVENTS_BUFFERING_MILLIS = 20
type ServerConnection (line 11) | type ServerConnection = ReturnType<typeof GenericServerConnection>
type ConnectionStatus (line 13) | type ConnectionStatus = "connecting" | "connected" | "sleeping" | "recon...
function getWebSocketRootUrl (line 15) | function getWebSocketRootUrl() {
function BrowserSideServerConnection (line 20) | function BrowserSideServerConnection(boardId: L.Property<string | undefi...
function GenericServerConnection (line 34) | function GenericServerConnection(
FILE: frontend/src/store/user-session-store.ts
type UserSessionState (line 10) | type UserSessionState = Anonymous | LoggingInServer | LoggedIn | LoggedO...
type StateId (line 12) | type StateId = UserSessionState["status"]
type BaseSessionState (line 14) | type BaseSessionState = {
type Anonymous (line 20) | type Anonymous = BaseSessionState & {
type LoggedOut (line 25) | type LoggedOut = BaseSessionState & {
type LoginFailedDueToTechnicalProblem (line 29) | type LoginFailedDueToTechnicalProblem = BaseSessionState & {
type LoggingInServer (line 36) | type LoggingInServer = BaseSessionState &
type LoggedIn (line 42) | type LoggedIn = BaseSessionState &
type UserSessionStore (line 49) | type UserSessionStore = ReturnType<typeof UserSessionStore>
function UserSessionStore (line 51) | function UserSessionStore(connection: ServerConnection, localStorage: St...
function getAuthenticatedUserFromCookie (line 144) | function getAuthenticatedUserFromCookie(): OAuthAuthenticatedUser | null {
function getUserJWT (line 156) | function getUserJWT() {
function canLogin (line 160) | function canLogin(state: UserSessionState): boolean {
function isLoginInProgress (line 166) | function isLoginInProgress(state: StateId): boolean {
function getAuthenticatedUser (line 170) | function getAuthenticatedUser(state: UserSessionState): OAuthAuthenticat...
function defaultAccessPolicy (line 178) | function defaultAccessPolicy(sessionState: UserSessionState, restrictAcc...
FILE: integration/src/compact-history.test.ts
type BundleDesc (line 8) | type BundleDesc = [Date, Serial, Serial]
function storeBundles (line 100) | async function storeBundles(boardId: Id, bundles: BundleDesc[]) {
function getBundles (line 106) | async function getBundles(boardId: Id) {
function addItems (line 112) | async function addItems(boardId: Id, firstSerial: Serial, lastSerial: Se...
FILE: perf-tester/src/create-boards.ts
constant BOARD_COUNT (line 4) | const BOARD_COUNT = parseInt(process.env.BOARD_COUNT || "1")
constant DOMAIN (line 5) | const DOMAIN = process.env.DOMAIN
constant API_ROOT (line 6) | const API_ROOT = `${DOMAIN ? "https" : "http"}://${DOMAIN ?? "localhost:...
constant CREATE_BOARD_API (line 7) | const CREATE_BOARD_API = `${API_ROOT}/api/v1/board`
function createBoardAndReturnId (line 9) | async function createBoardAndReturnId(n: number) {
FILE: perf-tester/src/index.ts
function add (line 26) | function add(a: Point, b: Point) {
function createTester (line 30) | function createTester(nickname: string, boardId: string) {
constant USER_COUNT (line 144) | const USER_COUNT = parseInt(process.env.USER_COUNT ?? "10")
constant BOARD_ID (line 145) | const BOARD_ID = process.env.BOARD_ID
constant BOARD_IDS (line 149) | const BOARD_IDS = BOARD_ID.split(",")
constant DOMAIN (line 150) | const DOMAIN = process.env.DOMAIN
constant NOTES_PER_SEC (line 152) | const NOTES_PER_SEC = parseFloat(process.env.NOTES_PER_SEC ?? "0.1")
constant TEXTS_PER_SEC (line 153) | const TEXTS_PER_SEC = parseFloat(process.env.TEXTS_PER_SEC ?? "0.0")
constant EDITS_PER_SEC (line 154) | const EDITS_PER_SEC = parseFloat(process.env.EDITS_PER_SEC ?? "0")
constant CURSOR_MOVES_PER_SEC (line 155) | const CURSOR_MOVES_PER_SEC = parseFloat(process.env.CURSOR_MOVES_PER_SEC...
FILE: playwright/src/pages/BoardApi.ts
function BoardApi (line 3) | function BoardApi(page: Page) {
FILE: playwright/src/pages/BoardPage.ts
function navigateToBoard (line 6) | async function navigateToBoard(page: Page, browser: Browser, boardId: st...
type NewBoardOptions (line 12) | type NewBoardOptions = Partial<{
type BoardPage (line 28) | type BoardPage = ReturnType<typeof BoardPage>
function BoardPage (line 29) | function BoardPage(page: Page, browser: Browser) {
function ContextMenu (line 342) | function ContextMenu(page: Page) {
FILE: playwright/src/pages/DashboardPage.ts
function navigateToDashboard (line 4) | async function navigateToDashboard(page: Page, browser: Browser) {
function DashboardPage (line 10) | function DashboardPage(page: Page, browser: Browser) {
FILE: playwright/src/tests/accessPolicy.spec.ts
function loginAsTester (line 5) | async function loginAsTester(page: Page) {
function logout (line 11) | async function logout(page: Page) {
FILE: playwright/src/tests/board.spec.ts
function resetSelection (line 280) | async function resetSelection() {
function testWithBothBoardTypes (line 321) | function testWithBothBoardTypes(
FILE: playwright/src/tests/collaboration.spec.ts
function createBoardWithTwoUsers (line 134) | async function createBoardWithTwoUsers(page: Page, browser: Browser) {
Condensed preview — 230 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,114K chars).
[
{
"path": ".dockerignore",
"chars": 160,
"preview": "backend/dist\nbackend/localfiles\nDockerfile\nfrontend/.cache\nignore\nlatest.dump*\nnode_modules\nnpm-debug.log\n**/node_module"
},
{
"path": ".editorconfig",
"chars": 163,
"preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 4\ncharset = utf-8\ntrim_trailing_whitespace = false\ninsert_final_newl"
},
{
"path": ".github/ISSUE_TEMPLATE/bug-report.md",
"chars": 314,
"preview": "---\nname: Bug Report\nabout: Create a bug report to help us out\ntitle: \"\"\nlabels: bug\nassignees: \"\"\n---\n\n# Feature reques"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 864,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[Feature Request]\"\nlabels: enhancement\nassigne"
},
{
"path": ".github/workflows/build.yml",
"chars": 3019,
"preview": "name: build\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n services:\n postgres:\n "
},
{
"path": ".gitignore",
"chars": 139,
"preview": ".cache/\nnode_modules/\ndist/\nyarn-error.log\n.env\n*.mp4\nbackend/localfiles/\n/ignore\n.DS_Store\nlatest.dump\n.vscode\nbackups\n"
},
{
"path": ".huskyrc.js",
"chars": 78,
"preview": "module.exports = {\n hooks: {\n \"pre-commit\": \"lint-staged\",\n },\n}\n"
},
{
"path": ".nvmrc",
"chars": 3,
"preview": "18\n"
},
{
"path": ".prettierignore",
"chars": 95,
"preview": "package.json\n.cache/\nnode_modules/\ndist/\nyarn-error.log\n.env\n*.mp4\nbackend/localfiles/\nignore/\n"
},
{
"path": ".prettierrc.js",
"chars": 85,
"preview": "module.exports = {\n printWidth: 120,\n semi: false,\n trailingComma: \"all\",\n}\n"
},
{
"path": "Dockerfile",
"chars": 859,
"preview": "FROM node:18 as builder\n\n# Create app directory\nWORKDIR /usr/src/app\n\nCOPY package.json yarn.lock ./\nCOPY frontend/packa"
},
{
"path": "LICENSE.txt",
"chars": 1166,
"preview": "This project is licensed under the MIT license.\nCopyrights are respective of each contributor listed at the beginning of"
},
{
"path": "README.md",
"chars": 18334,
"preview": "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 yo"
},
{
"path": "backend/migrations/001_init.js",
"chars": 1218,
"preview": "exports.up = (pgm) => {\n pgm.sql(`\n CREATE TABLE IF NOT EXISTS board (id text PRIMARY KEY, name text NOT NULL);\n "
},
{
"path": "backend/migrations/002_add_first_serial.js",
"chars": 285,
"preview": "exports.up = (pgm) => {\n pgm.sql(`\n ALTER TABLE board_event ADD COLUMN IF NOT EXISTS first_serial int;\n UPDATE "
},
{
"path": "backend/migrations/003_refactor_access.sql",
"chars": 858,
"preview": "alter table board add public_read boolean null;\nalter table board add public_write boolean null;\ncreate table board_acce"
},
{
"path": "backend/migrations/004_public_boards_access.sql",
"chars": 98,
"preview": "update board\nset public_read = 't',\n public_write = 't'\nwhere content -> 'accessPolicy' is null"
},
{
"path": "backend/migrations/005_uuid_extension.sql",
"chars": 43,
"preview": "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";"
},
{
"path": "backend/migrations/006_unique_email.sql",
"chars": 64,
"preview": "ALTER TABLE app_user ADD CONSTRAINT unique_email UNIQUE (email);"
},
{
"path": "backend/migrations/007_add_crdt_update.sql",
"chars": 58,
"preview": "alter table board_event add column crdt_update bytea null;"
},
{
"path": "backend/migrations/008_allow_crdt_only_bundle.sql",
"chars": 185,
"preview": "alter table board_event alter column first_serial drop not null;\nalter table board_event add constraint first_serial_or_"
},
{
"path": "backend/migrations/009_drop_board_event_primary_key.sql",
"chars": 121,
"preview": "ALTER TABLE board_event DROP CONSTRAINT board_event_pkey;\nCREATE INDEX board_event_board_index ON board_event (board_id)"
},
{
"path": "backend/migrations/010_drop_history_column.sql",
"chars": 38,
"preview": "alter table board drop column history;"
},
{
"path": "backend/package.json",
"chars": 1989,
"preview": "{\n \"name\": \"rboard-backend\",\n \"version\": \"1.0.0\",\n \"main\": \"dist/index.js\",\n \"license\": \"MIT\",\n \"dependencies\": {\n "
},
{
"path": "backend/src/api/api-routes.ts",
"chars": 674,
"preview": "import { router } from \"typera-express\"\nimport { boardCreate } from \"./board-create\"\nimport { boardCSVGet } from \"./boar"
},
{
"path": "backend/src/api/board-create.ts",
"chars": 1050,
"preview": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport { ok } from \"typera-common/response\"\nimpo"
},
{
"path": "backend/src/api/board-csv-get.ts",
"chars": 2625,
"preview": "import { createArrayCsvStringifier } from \"csv-writer\"\nimport _ from \"lodash\"\nimport { ok } from \"typera-common/response"
},
{
"path": "backend/src/api/board-get.ts",
"chars": 716,
"preview": "import { ok } from \"typera-common/response\"\nimport { apiTokenHeader, checkBoardAPIAccess, route } from \"./utils\"\nimport "
},
{
"path": "backend/src/api/board-hierarchy-get.ts",
"chars": 1358,
"preview": "import { ok } from \"typera-common/response\"\nimport { Board, Item } from \"../../../common/src/domain\"\nimport { apiTokenHe"
},
{
"path": "backend/src/api/board-history-get.ts",
"chars": 1672,
"preview": "import { ok, streamingBody } from \"typera-common/response\"\nimport { getFullBoardHistory } from \"../board-store\"\nimport {"
},
{
"path": "backend/src/api/board-update.ts",
"chars": 1281,
"preview": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport { ok } from \"typera-common/response\"\nimpo"
},
{
"path": "backend/src/api/github-webhook.ts",
"chars": 3011,
"preview": "import { encode as htmlEncode } from \"html-entities\"\nimport * as t from \"io-ts\"\nimport { badRequest, internalServerError"
},
{
"path": "backend/src/api/item-create-or-update.ts",
"chars": 4618,
"preview": "import * as t from \"io-ts\"\nimport { NonEmptyString } from \"io-ts-types\"\nimport _ from \"lodash\"\nimport { ok } from \"typer"
},
{
"path": "backend/src/api/item-create.ts",
"chars": 1384,
"preview": "import * as t from \"io-ts\"\nimport { ok } from \"typera-common/response\"\nimport { body } from \"typera-express/parser\"\nimpo"
},
{
"path": "backend/src/api/utils.ts",
"chars": 4650,
"preview": "import * as bodyParser from \"body-parser\"\nimport * as t from \"io-ts\"\nimport { badRequest, internalServerError, notFound "
},
{
"path": "backend/src/board-event-handler.ts",
"chars": 7481,
"preview": "import {\n AppEvent,\n BoardHistoryEntry,\n canWrite,\n checkBoardAccess,\n Id,\n isBoardItemEvent,\n isPe"
},
{
"path": "backend/src/board-state.test.ts",
"chars": 495,
"preview": "import { describe, expect, it } from \"vitest\"\n\ndescribe(\"board state iteration\", () => {\n it(\"is safe\", () => {\n "
},
{
"path": "backend/src/board-state.ts",
"chars": 7004,
"preview": "import { merge } from \"lodash\"\nimport { boardReducer } from \"../../common/src/board-reducer\"\nimport { Board, BoardCursor"
},
{
"path": "backend/src/board-store.ts",
"chars": 17409,
"preview": "import { PoolClient } from \"pg\"\nimport QueryStream from \"pg-query-stream\"\nimport * as uuid from \"uuid\"\nimport { boardRed"
},
{
"path": "backend/src/board-yjs-server.ts",
"chars": 3957,
"preview": "import expressWs from \"express-ws\"\nimport * as Y from \"yjs\"\nimport { updateBoardCrdt } from \"./board-state\"\nimport { get"
},
{
"path": "backend/src/common-event-handler.ts",
"chars": 5234,
"preview": "import * as Y from \"yjs\"\nimport { AppEvent, Board, CrdtEnabled, checkBoardAccess, defaultBoardSize } from \"../../common/"
},
{
"path": "backend/src/compact-history.ts",
"chars": 5222,
"preview": "import { format } from \"date-fns\"\nimport _ from \"lodash\"\nimport { BoardHistoryEntry, Id } from \"../../common/src/domain\""
},
{
"path": "backend/src/config.ts",
"chars": 1322,
"preview": "import path from \"path\"\nimport fs from \"fs\"\nimport { authProvider } from \"./oauth\"\nimport * as t from \"io-ts\"\nimport { o"
},
{
"path": "backend/src/connection-handler.ts",
"chars": 2386,
"preview": "import { AppEvent, Id, Serial, EventWrapper } from \"../../common/src/domain\"\nimport { getActiveBoards } from \"./board-st"
},
{
"path": "backend/src/db.ts",
"chars": 2150,
"preview": "import pg, { PoolClient } from \"pg\"\nimport process from \"process\"\nimport migrate from \"node-pg-migrate\"\n\nconst DATABASE_"
},
{
"path": "backend/src/decodeOrThrow.ts",
"chars": 609,
"preview": "import * as t from \"io-ts\"\nimport { Left, isLeft, left } from \"fp-ts/lib/Either\"\nimport { PathReporter } from \"io-ts/lib"
},
{
"path": "backend/src/env.ts",
"chars": 189,
"preview": "import process from \"process\"\n\nexport function getEnv(name: string): string {\n const value = process.env[name]\n if"
},
{
"path": "backend/src/expiring-map.ts",
"chars": 1619,
"preview": "export function AutoExpiringMap<V extends any>(ttlSeconds: number) {\n const timers = new Map<string | number, NodeJS."
},
{
"path": "backend/src/express-server.ts",
"chars": 4965,
"preview": "import dotenv from \"dotenv\"\nimport express from \"express\"\nimport expressWs from \"express-ws\"\nimport fs from \"fs\"\nimport "
},
{
"path": "backend/src/generic-oidc-auth.ts",
"chars": 4114,
"preview": "import { Request, Response } from \"express\"\nimport * as t from \"io-ts\"\nimport JWT from \"jsonwebtoken\"\nimport { OAuthAuth"
},
{
"path": "backend/src/github-webhook/example-payload.json",
"chars": 13518,
"preview": "{\n \"action\": \"assigned\",\n \"issue\": {\n \"url\": \"https://api.github.com/repos/raimohanska/r-board/issues/129\","
},
{
"path": "backend/src/google-auth.ts",
"chars": 2300,
"preview": "import { google } from \"googleapis\"\nimport { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport "
},
{
"path": "backend/src/host-config.ts",
"chars": 434,
"preview": "export const ROOT_URL = process.env.ROOT_URL ?? \"http://localhost:1337\"\nexport const ROOT_HOST = new URL(ROOT_URL).host\n"
},
{
"path": "backend/src/http-session.ts",
"chars": 1875,
"preview": "import Cookies from \"cookies\"\nimport { IncomingMessage, ServerResponse } from \"http\"\nimport JWT from \"jsonwebtoken\"\nimpo"
},
{
"path": "backend/src/locker.ts",
"chars": 1973,
"preview": "import { Id, BoardItemEvent, isPersistableBoardItemEvent, getItemIds, ItemLocks } from \"../../common/src/domain\"\nimport "
},
{
"path": "backend/src/oauth.ts",
"chars": 2265,
"preview": "import Cookies from \"cookies\"\nimport { Express, Request, Response } from \"express\"\nimport { OAuthAuthenticatedUser } fro"
},
{
"path": "backend/src/professions.ts",
"chars": 1271,
"preview": "export const professions = [\n \"Accountant\",\n \"Actress\",\n \"Architect\",\n \"Astronomer\",\n \"Author\",\n \"Bake"
},
{
"path": "backend/src/require-auth.ts",
"chars": 594,
"preview": "import { Express, Request, Response, NextFunction } from \"express\"\nimport { getAuthenticatedUser } from \"./http-session\""
},
{
"path": "backend/src/s3.ts",
"chars": 531,
"preview": "import * as AWS from \"aws-sdk\"\n\nconst s3Config = {\n region: \"eu-north-1\",\n apiVersion: \"2006-03-01\",\n signature"
},
{
"path": "backend/src/server.ts",
"chars": 1954,
"preview": "import dotenv from \"dotenv\"\ndotenv.config()\n\nimport * as Http from \"http\"\nimport { exampleBoard } from \"../../common/src"
},
{
"path": "backend/src/storage.ts",
"chars": 368,
"preview": "import { getSignedPutUrl as s3GetSignedPutUrl } from \"./s3\"\nimport { StorageBackend } from \"./config\"\n\nfunction localFSG"
},
{
"path": "backend/src/tools/wait-for-db.ts",
"chars": 306,
"preview": "import TcpPortUsed from \"tcp-port-used\"\nconst port = 13338\n;(async function () {\n console.log(`Waiting for DB to bind"
},
{
"path": "backend/src/user-store.ts",
"chars": 2202,
"preview": "import { inTransaction, withDBClient } from \"./db\"\nimport * as uuid from \"uuid\"\nimport { EventUserInfo, Id, RecentBoard,"
},
{
"path": "backend/src/uwebsockets-server.ts",
"chars": 3401,
"preview": "import uws from \"uWebSockets.js\"\nimport { EventFromServer } from \"../../common/src/domain\"\nimport * as uuid from \"uuid\"\n"
},
{
"path": "backend/src/websocket-sessions.ts",
"chars": 12550,
"preview": "import { OAuthAuthenticatedUser } from \"../../common/src/authenticated-user\"\nimport {\n AccessLevel,\n AckJoinBoard,"
},
{
"path": "backend/src/ws-wrapper.ts",
"chars": 1478,
"preview": "import * as WebSocket from \"ws\"\nimport * as uuid from \"uuid\"\nimport { EventFromServer } from \"../../common/src/domain\"\n\n"
},
{
"path": "backend/src/y-websocket-server/Docs.ts",
"chars": 1680,
"preview": "import { WSSharedDoc } from \"./WSSharedDoc\"\nimport { Persistence } from \"./Persistence\"\n\nexport interface DocsOptions {\n"
},
{
"path": "backend/src/y-websocket-server/Persistence.ts",
"chars": 953,
"preview": "import * as Y from \"yjs\"\n\nexport interface Persistence {\n bindState: (docName: string, ydoc: Y.Doc) => Promise<void>\n"
},
{
"path": "backend/src/y-websocket-server/Protocol.ts",
"chars": 63,
"preview": "export const messageSync = 0\nexport const messageAwareness = 1\n"
},
{
"path": "backend/src/y-websocket-server/WSSharedDoc.ts",
"chars": 3754,
"preview": "import * as Y from \"yjs\"\n\nimport * as awarenessProtocol from \"y-protocols/awareness\"\nimport * as syncProtocol from \"y-pr"
},
{
"path": "backend/src/y-websocket-server/YWebSocketServer.ts",
"chars": 4869,
"preview": "import * as awarenessProtocol from \"y-protocols/awareness\"\nimport * as syncProtocol from \"y-protocols/sync\"\n\nimport * as"
},
{
"path": "backend/tsconfig.json",
"chars": 174,
"preview": "{\n \"extends\": \"../tsconfig\",\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"outDir\": \"./dist\",\n "
},
{
"path": "benchmark/benchmark.ts",
"chars": 3670,
"preview": "import { uniqueId } from \"lodash\"\nimport { arrayToRecordById } from \"../common/src/arrays\"\nimport { boardReducer } from "
},
{
"path": "common/src/action-folding.ts",
"chars": 3471,
"preview": "import { arrayEquals, arrayIdAndKeysMatch, arrayIdMatch, idsOf } from \"./arrays\"\nimport {\n AppEvent,\n BoardHistory"
},
{
"path": "common/src/arrays.ts",
"chars": 1118,
"preview": "import { isArray, isEqual } from \"lodash\"\n\nexport function toArray<T>(x: T | T[]) {\n if (isArray(x)) return x\n ret"
},
{
"path": "common/src/assertNotNull.ts",
"chars": 155,
"preview": "export function assertNotNull<T>(x: T | null | undefined): T {\n if (x === null || x === undefined) throw Error(\"Asser"
},
{
"path": "common/src/authenticated-user.ts",
"chars": 123,
"preview": "export type OAuthAuthenticatedUser = {\n name: string\n email: string\n picture?: string\n domain: string | null"
},
{
"path": "common/src/board-crdt-helper.ts",
"chars": 1407,
"preview": "import * as Y from \"yjs\"\nimport { Board, Id, Item, QuillDelta, isTextItem } from \"./domain\"\n\nexport function getCRDTFiel"
},
{
"path": "common/src/board-reducer.benchmark.ts",
"chars": 1583,
"preview": "import _ from \"lodash\"\nimport { NOTE_COLORS } from \"./colors\"\nimport { Board } from \"./domain\"\nimport * as uuid from \"uu"
},
{
"path": "common/src/board-reducer.ts",
"chars": 26161,
"preview": "import { partition } from \"lodash\"\nimport { maybeChangeContainerForItem } from \"../../frontend/src/board/item-setcontain"
},
{
"path": "common/src/colors.ts",
"chars": 907,
"preview": "export const LIGHT_BLUE = \"#9FECFC\"\nexport const LIGHT_GREEN = \"#C8FC87\"\nexport const YELLOW = \"#FBFC86\"\nexport const OR"
},
{
"path": "common/src/connection-utils.ts",
"chars": 6475,
"preview": "import * as _ from \"lodash\"\nimport { maybeChangeContainerForConnection } from \"../../frontend/src/board/item-setcontaine"
},
{
"path": "common/src/domain.ts",
"chars": 24879,
"preview": "import * as t from \"io-ts\"\nimport * as uuid from \"uuid\"\nimport { LocalStorageBoard } from \"../../frontend/src/store/boar"
},
{
"path": "common/src/geometry.ts",
"chars": 2123,
"preview": "import { Point } from \"./domain\"\nexport const origin = { x: 0, y: 0 }\nexport type Coordinates = { x: number; y: number }"
},
{
"path": "common/src/migration.test.ts",
"chars": 7306,
"preview": "import { describe, expect, it } from \"vitest\"\nimport { arrayToRecordById } from \"./arrays\"\nimport {\n AddConnection,\n "
},
{
"path": "common/src/migration.ts",
"chars": 4729,
"preview": "import { isArray } from \"lodash\"\nimport { arrayToRecordById, toArray } from \"./arrays\"\nimport { resolveEndpoint } from \""
},
{
"path": "common/src/sets.ts",
"chars": 426,
"preview": "export function toggleInSet<T>(item: T, set: Set<T>) {\n if (set.has(item)) {\n return new Set([...set].filter(("
},
{
"path": "common/src/sleep.ts",
"chars": 131,
"preview": "export function sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(() => resolve(undefine"
},
{
"path": "common/src/vector2.ts",
"chars": 1308,
"preview": "export type Vector2 = { x: number; y: number }\n\nexport function Vector2(x: number, y: number) {\n return { x, y }\n}\n\ne"
},
{
"path": "cypress.json",
"chars": 102,
"preview": "{\n \"pluginsFile\": false,\n \"supportFile\": false,\n \"fixturesFolder\": false,\n \"retries\": 2\n}\n"
},
{
"path": "docker-compose.yaml",
"chars": 992,
"preview": "version: \"3.1\"\n\nservices:\n db:\n image: postgres:12\n restart: always\n ports:\n - 13338:"
},
{
"path": "frontend/.sassrc",
"chars": 38,
"preview": "{\n \"includePaths\": [\"node_modules\"]\n}"
},
{
"path": "frontend/esbuild.js",
"chars": 3367,
"preview": "require(\"dotenv\").config()\n\nconst sass = require(\"sass\")\nconst path = require(\"path\")\nconst fs = require(\"fs\")\nconst esb"
},
{
"path": "frontend/index.tmpl.html",
"chars": 714,
"preview": "<html lang=\"en\">\n <head>\n <title>OurBoard</title>\n <meta name=\"viewport\" content=\"width=device-width, i"
},
{
"path": "frontend/package.json",
"chars": 1689,
"preview": "{\n \"name\": \"rboard-frontend\",\n \"version\": \"1.0.0\",\n \"main\": \"index.js\",\n \"license\": \"MIT\",\n \"dependencies\": {\n \""
},
{
"path": "frontend/src/app.scss",
"chars": 313,
"preview": "@import \"style/variables.scss\";\n@import \"style/global.scss\";\n@import \"style/dashboard.scss\";\n@import \"style/utils.scss\";"
},
{
"path": "frontend/src/board/BoardView.tsx",
"chars": 16858,
"preview": "import * as H from \"harmaja\"\nimport { componentScope, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n "
},
{
"path": "frontend/src/board/BoardViewMessage.tsx",
"chars": 2077,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { signI"
},
{
"path": "frontend/src/board/CollaborativeTextView.tsx",
"chars": 5330,
"preview": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport Quill from \"quill\"\nimport QuillCursors fro"
},
{
"path": "frontend/src/board/ConnectionsView.tsx",
"chars": 10149,
"preview": "import { Fragment, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { findAttachmentLocation, resolveItemE"
},
{
"path": "frontend/src/board/CursorsView.tsx",
"chars": 3103,
"preview": "import { componentScope, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { UserCursorPosition, UserSessio"
},
{
"path": "frontend/src/board/DragBorder.tsx",
"chars": 1390,
"preview": "import { h, Fragment } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Connection } from \"../../../common/src/"
},
{
"path": "frontend/src/board/ImageView.tsx",
"chars": 2429,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Connection, Image } from \"../../../common/src/dom"
},
{
"path": "frontend/src/board/ItemView.tsx",
"chars": 6540,
"preview": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n AccessLevel,\n Board,\n Connecti"
},
{
"path": "frontend/src/board/RectangularDragSelection.tsx",
"chars": 615,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Rect } from \"../../../common/src/geometry\"\n\nexport const"
},
{
"path": "frontend/src/board/SaveAsTemplate.tsx",
"chars": 1031,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\n\nexport const "
},
{
"path": "frontend/src/board/SelectionBorder.tsx",
"chars": 4860,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimpor"
},
{
"path": "frontend/src/board/TextView.tsx",
"chars": 2805,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessLevel, Board, canWrite, getItemBackground, TextIte"
},
{
"path": "frontend/src/board/VideoView.tsx",
"chars": 2781,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimpor"
},
{
"path": "frontend/src/board/autoFontSize.ts",
"chars": 7270,
"preview": "import { isUndefined } from \"lodash\"\nimport * as L from \"lonna\"\nimport { getItemShape, Item, TextItem } from \"../../../c"
},
{
"path": "frontend/src/board/board-coordinates.ts",
"chars": 5917,
"preview": "import { componentScope } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { add, Coordinate"
},
{
"path": "frontend/src/board/board-drag.ts",
"chars": 7798,
"preview": "import { componentScope } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { connectionRect "
},
{
"path": "frontend/src/board/board-focus.ts",
"chars": 3289,
"preview": "import { HarmajaChild } from \"harmaja\"\nimport { Board, Connection, findConnection, findItem, Id, Item } from \"../../../c"
},
{
"path": "frontend/src/board/board-permissions.ts",
"chars": 1060,
"preview": "import { Connection, Item } from \"../../../common/src/domain\"\n\nexport const canChangeFont: BoardPermission = (item) => !"
},
{
"path": "frontend/src/board/board-scroll-and-zoom.ts",
"chars": 9435,
"preview": "import * as H from \"harmaja\"\nimport { componentScope } from \"harmaja\"\nimport _, { clamp } from \"lodash\"\nimport * as L fr"
},
{
"path": "frontend/src/board/boardContentArea.ts",
"chars": 3329,
"preview": "import _ from \"lodash\"\nimport { Board } from \"../../../common/src/domain\"\nimport { Rect } from \"../../../common/src/geom"
},
{
"path": "frontend/src/board/contextmenu/ContextMenuView.tsx",
"chars": 5235,
"preview": "import { h, HarmajaOutput, ListView } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Board, C"
},
{
"path": "frontend/src/board/contextmenu/alignments.tsx",
"chars": 8119,
"preview": "import { componentScope, h } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Item } from \"../."
},
{
"path": "frontend/src/board/contextmenu/areaTiling.tsx",
"chars": 2735,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Container, Item, findItem, isContainer } from \".."
},
{
"path": "frontend/src/board/contextmenu/colors.tsx",
"chars": 1983,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { DEFAULT_NOTE_COLOR, NOTE_COLORS, TRANSPARENT } from \"../"
},
{
"path": "frontend/src/board/contextmenu/colorsAndShapes.tsx",
"chars": 1854,
"preview": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { NOTE_COLORS } from \"../../../../common/s"
},
{
"path": "frontend/src/board/contextmenu/connection-ends.tsx",
"chars": 5002,
"preview": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { rerouteConnection } from \"../../../../co"
},
{
"path": "frontend/src/board/contextmenu/fontSizes.tsx",
"chars": 2302,
"preview": "import { HarmajaOutput, componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { isTextItem } from \"../../"
},
{
"path": "frontend/src/board/contextmenu/hideContents.tsx",
"chars": 1724,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { isContainer } from \"../../../../common/src/domain\"\nimpor"
},
{
"path": "frontend/src/board/contextmenu/lock.tsx",
"chars": 2049,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { LockIcon, UnlockIcon } from \"../../components/Icons\"\nimp"
},
{
"path": "frontend/src/board/contextmenu/shapes.tsx",
"chars": 2735,
"preview": "import { h, HarmajaOutput } from \"harmaja\"\nimport * as _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Color, isShap"
},
{
"path": "frontend/src/board/contextmenu/textAlignments.tsx",
"chars": 4412,
"preview": "import { HarmajaOutput, componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport {\n Align,\n Color,\n "
},
{
"path": "frontend/src/board/contextmenu/textFormats.tsx",
"chars": 2698,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { CrdtEnabled, isTextItem } from \"../../../../common/src/d"
},
{
"path": "frontend/src/board/contrasting-color.ts",
"chars": 4991,
"preview": "interface RGB {\n b: number\n g: number\n r: number\n}\nfunction rgbToYIQ({ r, g, b }: RGB): number {\n return (r "
},
{
"path": "frontend/src/board/double-click.ts",
"chars": 1122,
"preview": "import { componentScope } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { IS_TOUCHSCREEN, isSingleTouch } from \"./tou"
},
{
"path": "frontend/src/board/header/BoardViewHeader.tsx",
"chars": 6919,
"preview": "import { Fragment, h } from \"harmaja\"\nimport { getNavigator } from \"harmaja-router\"\nimport * as L from \"lonna\"\nimport { "
},
{
"path": "frontend/src/board/header/OtherUsersView.tsx",
"chars": 3061,
"preview": "import { ListView, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, UserSessionInfo } from \"../../../../com"
},
{
"path": "frontend/src/board/header/SharingModalDialog.tsx",
"chars": 2799,
"preview": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, checkBoardAccess } from \"../../../../co"
},
{
"path": "frontend/src/board/header/UserInfoModal.tsx",
"chars": 3800,
"preview": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { TextInput } from \"../../components/components\""
},
{
"path": "frontend/src/board/header/UserInfoView.tsx",
"chars": 1631,
"preview": "import { componentScope, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { UserSessionInfo } from \"../../../../comm"
},
{
"path": "frontend/src/board/image-upload.ts",
"chars": 3644,
"preview": "import * as L from \"lonna\"\nimport { Item, newImage, newVideo } from \"../../../common/src/domain\"\nimport { AssetStore, As"
},
{
"path": "frontend/src/board/item-connect.ts",
"chars": 11166,
"preview": "import _, { isEqual } from \"lodash\"\nimport * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport * as uuid from"
},
{
"path": "frontend/src/board/item-create.ts",
"chars": 1386,
"preview": "import * as L from \"lonna\"\nimport { Board, Item, newContainer, newSimilarNote, newText, Note } from \"../../../common/src"
},
{
"path": "frontend/src/board/item-cut-copy-paste.ts",
"chars": 11008,
"preview": "import _ from \"lodash\"\nimport * as L from \"lonna\"\nimport * as uuid from \"uuid\"\nimport { DEFAULT_NOTE_COLOR } from \"../.."
},
{
"path": "frontend/src/board/item-delete.ts",
"chars": 866,
"preview": "import * as L from \"lonna\"\nimport { Id } from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-stor"
},
{
"path": "frontend/src/board/item-drag.ts",
"chars": 6103,
"preview": "import * as L from \"lonna\"\nimport { Board, Connection, getConnection, getItem, Item, Point } from \"../../../common/src/d"
},
{
"path": "frontend/src/board/item-dragmove.ts",
"chars": 4056,
"preview": "import * as L from \"lonna\"\nimport { BoardCoordinateHelper } from \"./board-coordinates\"\nimport { Board, BOARD_ITEM_BORDER"
},
{
"path": "frontend/src/board/item-duplicate.ts",
"chars": 1338,
"preview": "import * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { emptySet } from \"../../../common/"
},
{
"path": "frontend/src/board/item-hide-contents.ts",
"chars": 1574,
"preview": "import { Board, isContainer, Item, Note } from \"../../../common/src/domain\"\nimport { Dispatch } from \"../store/board-sto"
},
{
"path": "frontend/src/board/item-move-with-arrow-keys.ts",
"chars": 2878,
"preview": "import * as L from \"lonna\"\nimport { connectionRect, resolveEndpoint } from \"../../../common/src/connection-utils\"\nimport"
},
{
"path": "frontend/src/board/item-organizer.test.ts",
"chars": 2149,
"preview": "import { describe, expect, it } from \"vitest\"\n\nimport { newNote } from \"../../../common/src/domain\"\nimport { overlaps } "
},
{
"path": "frontend/src/board/item-organizer.ts",
"chars": 3476,
"preview": "import { Board, Container, Item, ItemUpdate } from \"../../../common/src/domain\"\nimport * as G from \"../../../common/src/"
},
{
"path": "frontend/src/board/item-packer.ts",
"chars": 4228,
"preview": "// @ts-ignore\nimport { BP2D } from \"binpackingjs\"\nconst { Bin, Box, Packer, heuristics } = BP2D\nimport { Board, Containe"
},
{
"path": "frontend/src/board/item-select-all.ts",
"chars": 552,
"preview": "import * as L from \"lonna\"\nimport { Board } from \"../../../common/src/domain\"\nimport { BoardFocus } from \"./board-focus\""
},
{
"path": "frontend/src/board/item-selection.ts",
"chars": 2949,
"preview": "import * as L from \"lonna\"\nimport { Board, Connection, ItemType } from \"../../../common/src/domain\"\nimport { emptySet, t"
},
{
"path": "frontend/src/board/item-setcontainer.ts",
"chars": 1257,
"preview": "import { isFullyContainedConnection } from \"../../../common/src/connection-utils\"\nimport { Board, Connection, Id, Item }"
},
{
"path": "frontend/src/board/item-undo-redo.ts",
"chars": 306,
"preview": "import { Dispatch } from \"../store/board-store\"\nimport { controlKey, installKeyboardShortcut } from \"./keyboard-shortcut"
},
{
"path": "frontend/src/board/keyboard-shortcuts.ts",
"chars": 1056,
"preview": "import { componentScope } from \"harmaja\"\nimport * as L from \"lonna\"\n\nexport function installKeyboardShortcut(\n select"
},
{
"path": "frontend/src/board/local-storage-atom.ts",
"chars": 325,
"preview": "import * as L from \"lonna\"\n\nexport function localStorageAtom<T>(key: string, defaultValue: T) {\n const initialValue ="
},
{
"path": "frontend/src/board/quillClickableLink.ts",
"chars": 423,
"preview": "import Quill from \"quill\"\nvar Link = Quill.import(\"formats/link\")\n\nexport default class ClickableLink extends Link {\n "
},
{
"path": "frontend/src/board/quillPasteLinkOverText.ts",
"chars": 1108,
"preview": "import Quill from \"quill\"\nimport { isURL } from \"../components/sanitizeHTML\"\n\nexport default class PasteLinkOverText {\n "
},
{
"path": "frontend/src/board/synchronize-focus-with-server.ts",
"chars": 3494,
"preview": "import * as L from \"lonna\"\nimport * as _ from \"lodash\"\nimport { Board, Id, ItemLocks } from \"../../../common/src/domain\""
},
{
"path": "frontend/src/board/tool-selection.ts",
"chars": 921,
"preview": "import { localStorageAtom } from \"./local-storage-atom\"\nimport * as L from \"lonna\"\n\nexport type Tool = \"pan\" | \"select\" "
},
{
"path": "frontend/src/board/toolbars/BackToAllBoardsLink.tsx",
"chars": 404,
"preview": "import { h } from \"harmaja\"\nimport { Link } from \"harmaja-router\"\nimport { Routes } from \"../../board-navigation\"\nimport"
},
{
"path": "frontend/src/board/toolbars/BoardToolLayer.tsx",
"chars": 4934,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessLevel, Board, canWrite, Item, Note } from \"../../."
},
{
"path": "frontend/src/board/toolbars/MainToolBar.tsx",
"chars": 8476,
"preview": "import { h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item, Note } from \"../../../../common/src/domain\""
},
{
"path": "frontend/src/board/toolbars/MiniMapView.tsx",
"chars": 4494,
"preview": "import { h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item } from \"../../../../common/src/dom"
},
{
"path": "frontend/src/board/toolbars/PaletteView.tsx",
"chars": 7636,
"preview": "import { h, Fragment, HarmajaChild } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Board, Item, newContainer, newSi"
},
{
"path": "frontend/src/board/toolbars/ToolSelector.tsx",
"chars": 5687,
"preview": "import { Fragment, h, HarmajaChild } from \"harmaja\"\nimport { capitalize } from \"lodash\"\nimport * as L from \"lonna\"\nimpor"
},
{
"path": "frontend/src/board/toolbars/UndoRedo.tsx",
"chars": 675,
"preview": "import { h } from \"harmaja\"\nimport { RedoIcon, UndoIcon } from \"../../components/Icons\"\nimport { BoardStore, Dispatch } "
},
{
"path": "frontend/src/board/toolbars/ZoomControls.tsx",
"chars": 907,
"preview": "import { h } from \"harmaja\"\nimport { ResetZoomIcon, ZoomInIcon, ZoomOutIcon } from \"../../components/Icons\"\nimport { Zoo"
},
{
"path": "frontend/src/board/touchScreen.ts",
"chars": 480,
"preview": "export const IS_TOUCHSCREEN = \"ontouchstart\" in window\n\nexport function getSingleTouch(e: TouchEvent | JSX.TouchEvent) {"
},
{
"path": "frontend/src/board/zIndices.ts",
"chars": 300,
"preview": "import { Item } from \"../../../common/src/domain\"\n\nexport const Z_CONTAINERS_UP_TO = 1000000\nexport const Z_CONNECTIONS "
},
{
"path": "frontend/src/board/zoom-shortcuts.ts",
"chars": 528,
"preview": "import { ZoomAndScrollControls } from \"./board-scroll-and-zoom\"\nimport { controlKey, installKeyboardShortcut } from \"./k"
},
{
"path": "frontend/src/board-navigation.ts",
"chars": 1493,
"preview": "import { HarmajaRouter, Navigator } from \"harmaja-router\"\nimport * as L from \"lonna\"\nimport { Board, BoardStub, EventFro"
},
{
"path": "frontend/src/components/BoardAccessPolicyEditor.tsx",
"chars": 4592,
"preview": "import { Fragment, h, ListView } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { AccessListEntry, BoardAccessPolicy, "
},
{
"path": "frontend/src/components/BoardCrdtModeSelector.tsx",
"chars": 551,
"preview": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\nimport { Checkbox } from \"./components\"\n\ntype BoardCrdt"
},
{
"path": "frontend/src/components/EditableSpan.tsx",
"chars": 3091,
"preview": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { h, HarmajaOutput } from \"harmaja\"\nimport { isFirefox } "
},
{
"path": "frontend/src/components/HTMLEditableSpan.tsx",
"chars": 4801,
"preview": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { componentScope, h } from \"harmaja\"\nimport { isURL, sani"
},
{
"path": "frontend/src/components/Icons.tsx",
"chars": 15532,
"preview": "import { h } from \"harmaja\"\nimport { Color } from \"../../../common/src/domain\"\nimport * as L from \"lonna\"\nimport { black"
},
{
"path": "frontend/src/components/ModalContainer.tsx",
"chars": 2106,
"preview": "import { Fragment, h } from \"harmaja\"\nimport * as L from \"lonna\"\n\nexport function ModalContainer({ content }: { content:"
},
{
"path": "frontend/src/components/UIColors.ts",
"chars": 111,
"preview": "export const selectedColor = \"#2F80ED\"\nexport const black = \"#00263A\"\nexport const disabledColor = \"lightgrey\"\n"
},
{
"path": "frontend/src/components/browser.ts",
"chars": 83,
"preview": "export const isFirefox = navigator.userAgent.toLowerCase().indexOf(\"firefox\") > -1\n"
},
{
"path": "frontend/src/components/components.tsx",
"chars": 1274,
"preview": "import * as H from \"harmaja\"\nimport * as L from \"lonna\"\nimport { componentScope, h, HarmajaOutput } from \"harmaja\"\n\nexpo"
},
{
"path": "frontend/src/components/onClickOutside.tsx",
"chars": 411,
"preview": "import * as L from \"lonna\"\nimport { h, componentScope } from \"harmaja\"\n\nexport function onClickOutside(elem: L.Property<"
},
{
"path": "frontend/src/components/sanitizeHTML.ts",
"chars": 2667,
"preview": "import sh, { Attributes } from \"sanitize-html\"\n\nconst sanitizeConfig = {\n allowedTags: [\n \"b\",\n \"i\",\n "
},
{
"path": "frontend/src/dashboard/DashboardView.tsx",
"chars": 15358,
"preview": "import { Fragment, h, ListView } from \"harmaja\"\nimport { getNavigator, Link } from \"harmaja-router\"\nimport * as L from \""
},
{
"path": "frontend/src/embedding.tsx",
"chars": 146,
"preview": "const search = new URLSearchParams(location.search)\n\nconst embedded = search.get(\"embedded\") === \"true\"\n\nexport const is"
},
{
"path": "frontend/src/google-auth.ts",
"chars": 433,
"preview": "import LocalStore from \"./store/board-local-store\"\n\nexport function signIn() {\n document.location.assign(\"/login?retu"
},
{
"path": "frontend/src/index.tsx",
"chars": 3917,
"preview": "import * as H from \"harmaja\"\nimport { h } from \"harmaja\"\nimport _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { Even"
},
{
"path": "frontend/src/store/asset-store.ts",
"chars": 4428,
"preview": "import * as L from \"lonna\"\nimport { AppEvent, AssetPutUrlResponse, Board } from \"../../../common/src/domain\"\nimport md5 "
},
{
"path": "frontend/src/store/board-local-store.ts",
"chars": 3457,
"preview": "import * as localForage from \"localforage\"\nimport { throttle } from \"lodash\"\nimport { Board, BoardHistoryEntry, Id } fro"
},
{
"path": "frontend/src/store/board-store.test.ts",
"chars": 14595,
"preview": "import { describe, expect, it } from \"vitest\"\n\nimport { BoardStore } from \"./board-store\"\nimport * as L from \"lonna\"\nimp"
},
{
"path": "frontend/src/store/board-store.ts",
"chars": 25672,
"preview": "import _ from \"lodash\"\nimport * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { addOrReplaceEvent, foldAc"
},
{
"path": "frontend/src/store/crdt-store.ts",
"chars": 4315,
"preview": "import * as L from \"lonna\"\nimport { IndexeddbPersistence } from \"y-indexeddb\"\nimport { WebsocketProvider } from \"y-webso"
},
{
"path": "frontend/src/store/cursors-store.ts",
"chars": 1649,
"preview": "import { ServerConnection } from \"./server-connection\"\nimport { CURSOR_POSITIONS_ACTION_TYPE, CursorPositions, UserCurso"
},
{
"path": "frontend/src/store/recent-boards.ts",
"chars": 2298,
"preview": "import { Board, Id, newISOTimeStamp, RecentBoard, RecentBoardAttributes } from \"../../../common/src/domain\"\nimport * as "
},
{
"path": "frontend/src/store/server-connection.ts",
"chars": 5449,
"preview": "import * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { CURSORS_ONLY, addOrReplaceEvent } from \"../../.."
},
{
"path": "frontend/src/store/user-session-store.ts",
"chars": 6473,
"preview": "import * as L from \"lonna\"\nimport { globalScope } from \"lonna\"\nimport { OAuthAuthenticatedUser } from \"../../../common/s"
},
{
"path": "frontend/src/style/board.scss",
"chars": 16167,
"preview": "#root.board-container {\n width: 100%;\n height: calc(100vh - 2em);\n display: flex;\n flex-direction: column;\n "
},
{
"path": "frontend/src/style/dashboard.scss",
"chars": 4568,
"preview": "#root.dashboard {\n background-color: #f4f4f6;\n display: flex;\n align-items: center;\n justify-content: flex-s"
},
{
"path": "frontend/src/style/global.scss",
"chars": 1709,
"preview": "html {\n font-family: $font-family;\n color: $black;\n font-size: 16px;\n}\n\n#root {\n min-height: 100vh;\n disp"
},
{
"path": "frontend/src/style/header.scss",
"chars": 3849,
"preview": "#root.board-container.not-found header {\n #board-info {\n visibility: hidden;\n }\n}\n#root.board-container hea"
},
{
"path": "frontend/src/style/modal.scss",
"chars": 1020,
"preview": "@import \"./variables.scss\";\n\n.modal-container {\n background-color: rgba(200, 200, 200, 0.6);\n position: fixed;\n "
},
{
"path": "frontend/src/style/sharing-modal.scss",
"chars": 213,
"preview": "@import \"./variables.scss\";\n\n.modal-dialog .sharing {\n h2:not(:first-of-type) {\n margin-top: 1em;\n }\n\n ."
}
]
// ... and 30 more files (download for full content)
About this extraction
This page contains the full source code of the raimohanska/ourboard GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 230 files (1.0 MB), approximately 297.4k tokens, and a symbol index with 752 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.