Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f716c498e5 | |||
| 29a4147b5e | |||
| 15389156d3 | |||
| 168904728b | |||
| 3ca07e0f4c | |||
| 6098fe1e14 | |||
| a2f1e32f21 | |||
| f27e968c15 | |||
| 971b7e099d | |||
| 6d0ccb15ea | |||
| ef8ee67553 | |||
| ad47fc2d60 | |||
| 009f5d6ed4 | |||
| 64d0072c8d | |||
| aefbc2e0b9 | |||
| 15dc1e3012 | |||
| 6cc20aeacb | |||
| 7da7214afb | |||
| c369419512 | |||
| d9ad397c94 | |||
| 3191d890f3 | |||
| 68f3387539 | |||
| 0dc8b4556c | |||
| e123e91959 | |||
| 2709400773 | |||
| 8281c6159b |
@@ -6,6 +6,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'feature/collab-long-polling'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
@@ -88,28 +88,6 @@ jobs:
|
||||
- name: Start Docker services
|
||||
run: make bootstrap FLUSH_ARGS='--no-input' cache=
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for services to be ready
|
||||
run: |
|
||||
printf "Minio check...\n"
|
||||
dockerize -wait tcp://localhost:9000 -timeout 20s
|
||||
printf "Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:8080 -timeout 20s
|
||||
printf "Server collaboration check...\n"
|
||||
dockerize -wait tcp://localhost:4444 -timeout 20s
|
||||
printf "Ngnix check...\n"
|
||||
dockerize -wait tcp://localhost:8083 -timeout 20s
|
||||
printf "DRF check...\n"
|
||||
dockerize -wait tcp://localhost:8071 -timeout 20s
|
||||
printf "Postgres Keyclock check...\n"
|
||||
dockerize -wait tcp://localhost:5433 -timeout 20s
|
||||
printf "Postgres back check...\n"
|
||||
dockerize -wait tcp://localhost:15432 -timeout 20s
|
||||
|
||||
- name: Run e2e tests
|
||||
run: cd src/frontend/ && yarn e2e:test --project='chromium'
|
||||
|
||||
|
||||
+20
-22
@@ -6,9 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨Collaboration long polling fallback #517
|
||||
|
||||
## Changed
|
||||
|
||||
- 🛂(frontend) Restore version visibility #629
|
||||
- 📝(doc) minor README.md formatting and wording enhancements
|
||||
- ♻️Stop setting a default title on doc creation #634
|
||||
|
||||
## Fixed
|
||||
|
||||
- ♻️(frontend) improve table pdf rendering
|
||||
|
||||
|
||||
## [2.2.0] - 2025-02-10
|
||||
|
||||
## Added
|
||||
@@ -28,7 +42,6 @@ and this project adheres to
|
||||
- 🐛(frontend) fix cursor breakline #609
|
||||
- 🐛(frontend) fix style pdf export #609
|
||||
|
||||
|
||||
## [2.1.0] - 2025-01-29
|
||||
|
||||
## Added
|
||||
@@ -44,7 +57,7 @@ and this project adheres to
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) add abilities on doc row #581
|
||||
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||
- 🔧(backend) Bump maximum page size to 200 #516
|
||||
- 📝(doc) Improve Read me #558
|
||||
|
||||
@@ -56,7 +69,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove "content" field from list serializer # 516
|
||||
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
|
||||
## Fixed
|
||||
@@ -111,12 +123,11 @@ and this project adheres to
|
||||
|
||||
- ⚡️(e2e) reduce flakiness on e2e tests #511
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) update doc editor height #481
|
||||
- 💄(frontend) add doc search #485
|
||||
|
||||
|
||||
## [1.9.0] - 2024-12-11
|
||||
|
||||
## Added
|
||||
@@ -138,21 +149,18 @@ and this project adheres to
|
||||
- 🐛(frontend) Fix hidden menu on Firefox #468
|
||||
- 🐛(backend) fix sanitize problem IA #490
|
||||
|
||||
|
||||
## [1.8.2] - 2024-11-28
|
||||
|
||||
## Changed
|
||||
|
||||
- ♻️(SW) change strategy html caching #460
|
||||
|
||||
|
||||
## [1.8.1] - 2024-11-27
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) link not clickable and flickering firefox #457
|
||||
|
||||
|
||||
## [1.8.0] - 2024-11-25
|
||||
|
||||
## Added
|
||||
@@ -181,7 +189,6 @@ and this project adheres to
|
||||
- 🐛(frontend) users have view access when revoked #387
|
||||
- 🐛(frontend) fix placeholder editable when double clicks #454
|
||||
|
||||
|
||||
## [1.7.0] - 2024-10-24
|
||||
|
||||
## Added
|
||||
@@ -209,7 +216,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(helm) remove infra related codes #366
|
||||
|
||||
|
||||
## [1.6.0] - 2024-10-17
|
||||
|
||||
## Added
|
||||
@@ -232,7 +238,6 @@ and this project adheres to
|
||||
- 🐛(backend) fix nginx docker container #340
|
||||
- 🐛(frontend) fix copy paste firefox #353
|
||||
|
||||
|
||||
## [1.5.1] - 2024-10-10
|
||||
|
||||
## Fixed
|
||||
@@ -267,7 +272,6 @@ and this project adheres to
|
||||
- 🔧(backend) fix configuration to avoid different ssl warning #297
|
||||
- 🐛(frontend) fix editor break line not working #302
|
||||
|
||||
|
||||
## [1.4.0] - 2024-09-17
|
||||
|
||||
## Added
|
||||
@@ -288,7 +292,6 @@ and this project adheres to
|
||||
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
|
||||
- 🐛 Rebuild frontend dev container from makefile #248
|
||||
|
||||
|
||||
## [1.3.0] - 2024-09-05
|
||||
|
||||
## Added
|
||||
@@ -313,7 +316,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(frontend) remove saving modal #213
|
||||
|
||||
|
||||
## [1.2.1] - 2024-08-23
|
||||
|
||||
## Changed
|
||||
@@ -321,7 +323,6 @@ and this project adheres to
|
||||
- ♻️ Change ordering docs datagrid #195
|
||||
- 🔥(helm) use scaleway email #194
|
||||
|
||||
|
||||
## [1.2.0] - 2024-08-22
|
||||
|
||||
## Added
|
||||
@@ -345,14 +346,14 @@ and this project adheres to
|
||||
- ⚡️(CI) only e2e chrome mandatory #177
|
||||
|
||||
## Removed
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
- 🔥(helm) remove htaccess #181
|
||||
|
||||
## [1.1.0] - 2024-07-15
|
||||
|
||||
## Added
|
||||
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
- 🤡(demo) generate dummy documents on dev users #120
|
||||
- ✨(frontend) create side modal component #134
|
||||
- ✨(frontend) Doc grid actions (update / delete) #136
|
||||
- ✨(frontend) Doc editor header information #137
|
||||
@@ -363,12 +364,11 @@ and this project adheres to
|
||||
- ♻️(frontend) create a doc from a modal #132
|
||||
- ♻️(frontend) manage members from the share modal #140
|
||||
|
||||
|
||||
## [1.0.0] - 2024-07-02
|
||||
|
||||
## Added
|
||||
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- ✨(frontend) Update document (#68)
|
||||
- ✨(frontend) Remove document (#68)
|
||||
- 🐳(docker) dockerize dev frontend (#63)
|
||||
@@ -402,7 +402,6 @@ and this project adheres to
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
|
||||
## Added
|
||||
@@ -410,7 +409,6 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.2.0...main
|
||||
[v2.2.0]: https://github.com/numerique-gouv/impress/releases/v2.2.0
|
||||
[v2.1.0]: https://github.com/numerique-gouv/impress/releases/v2.1.0
|
||||
|
||||
@@ -44,7 +44,6 @@ COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
@@ -81,12 +80,12 @@ bootstrap: \
|
||||
data/static \
|
||||
create-env-files \
|
||||
build \
|
||||
run-with-frontend \
|
||||
migrate \
|
||||
demo \
|
||||
back-i18n-compile \
|
||||
mails-install \
|
||||
mails-build
|
||||
mails-build \
|
||||
run
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
@@ -109,7 +108,7 @@ build-yjs-provider: ## build the y-provider container
|
||||
|
||||
build-frontend: cache ?=
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend-dev $(cache)
|
||||
@$(COMPOSE) build frontend $(cache)
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@@ -120,18 +119,17 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run-backend: ## Start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d y-provider
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run
|
||||
.PHONY: run-backend
|
||||
|
||||
run-with-frontend: ## Start all the containers needed (backend to frontend)
|
||||
@$(MAKE) run
|
||||
@$(COMPOSE) up --force-recreate -d frontend-dev
|
||||
.PHONY: run-with-frontend
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@$(COMPOSE) ps
|
||||
@@ -188,14 +186,12 @@ test-back-parallel: ## run all back-end tests in parallel
|
||||
makemigrations: ## run django makemigrations for the impress project.
|
||||
@echo "$(BOLD)Running makemigrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) makemigrations
|
||||
.PHONY: makemigrations
|
||||
|
||||
migrate: ## run django migrations for the impress project.
|
||||
@echo "$(BOLD)Running migrations$(RESET)"
|
||||
@$(COMPOSE) up -d postgresql
|
||||
@$(WAIT_DB)
|
||||
@$(MANAGE) migrate
|
||||
.PHONY: migrate
|
||||
|
||||
@@ -310,16 +306,16 @@ help:
|
||||
.PHONY: help
|
||||
|
||||
# Front
|
||||
frontend-install: ## install the frontend locally
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn
|
||||
.PHONY: frontend-install
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && yarn lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
run-frontend-development: ## Run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend-dev
|
||||
@$(COMPOSE) stop frontend
|
||||
cd $(PATH_FRONT_IMPRESS) && yarn dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ Welcome to Docs! The open source document editor where your notes can become kno
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
## Why use Docs ❓
|
||||
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
### Write
|
||||
@@ -33,23 +34,31 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
|
||||
### Collaborate
|
||||
* 🤝 Collaborate in realtime with your team mates
|
||||
* 🔒 Granular access control to keep your information secure and shared with the right people
|
||||
* 🤝 Collaborate with your team in real time
|
||||
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
## Getting started 🔧
|
||||
|
||||
### Test it
|
||||
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
|
||||
### Run it locally
|
||||
|
||||
> ⚠️ Running Docs locally using the methods described below is for testing purposes only. It is based on building Docs using Minio as the S3 storage solution: if you want to use Minio for production deployment of Docs, you will need to comply with Minio's AGPL-3.0 licence.
|
||||
|
||||
**Prerequisite**
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```shellscript
|
||||
@@ -57,23 +66,22 @@ $ docker -v
|
||||
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
$ docker compose version
|
||||
|
||||
docker compose version 1.27.4, build 40524192
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by adding your user to the `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
|
||||
|
||||
command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this command each time you are pulling code from the project repository to avoid dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
@@ -89,7 +97,7 @@ password: impress
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```shellscript
|
||||
$ make run-with-frontend
|
||||
$ make run
|
||||
```
|
||||
|
||||
⚠️ For the frontend developer, it is often better to run the frontend in development mode locally.
|
||||
@@ -97,7 +105,7 @@ $ make run-with-frontend
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```shellscript
|
||||
$ make frontend-install
|
||||
$ make frontend-development-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
@@ -109,7 +117,7 @@ $ make run-frontend-development
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```shellscript
|
||||
$ make run
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
**Adding content**
|
||||
@@ -126,6 +134,7 @@ $ make help
|
||||
```
|
||||
|
||||
**Django admin**
|
||||
|
||||
You can access the Django admin site at
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
@@ -137,17 +146,21 @@ $ make superuser
|
||||
```
|
||||
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
## Roadmap
|
||||
|
||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||
|
||||
## Licence 📝
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
While Docs is a public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to [get in touch](https://matrix.to/#/#docs-official:matrix.org) if you have any question related to our implementation or design decisions.
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
@@ -169,10 +182,13 @@ docs
|
||||
```
|
||||
|
||||
## Credits ❤️
|
||||
|
||||
### Stack
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/)
|
||||
|
||||
Docs is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [BlockNote.js](https://www.blocknotejs.org/), [HocusPocus](https://tiptap.dev/docs/hocuspocus/introduction) and [Yjs](https://yjs.dev/).
|
||||
|
||||
### Gov ❤️ open source
|
||||
|
||||
Docs is the result of a joint effort led by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 governments ([ZenDiS](https://zendis.de/)).
|
||||
|
||||
We are proud sponsors of [BlockNotejs](https://www.blocknotejs.org/) and [Yjs](https://yjs.dev/).
|
||||
|
||||
+1
-3
@@ -7,7 +7,6 @@ UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="docs"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
@@ -40,9 +39,8 @@ function _set_user() {
|
||||
# ARGS : docker compose command arguments
|
||||
function _docker_compose() {
|
||||
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
echo "🐳(compose) file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
|
||||
+55
-17
@@ -1,6 +1,13 @@
|
||||
name: docs
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:16
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
env_file:
|
||||
- env.d/development/postgresql
|
||||
ports:
|
||||
@@ -15,7 +22,7 @@ services:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
# user: ${DOCKER_USER:-1000}
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=impress
|
||||
@@ -23,6 +30,11 @@ services:
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
@@ -31,7 +43,9 @@ services:
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set impress http://minio:9000 impress password && \
|
||||
@@ -59,10 +73,15 @@ services:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
mailcatcher:
|
||||
condition: service_started
|
||||
redis:
|
||||
condition: service_started
|
||||
createbuckets:
|
||||
condition: service_started
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -93,9 +112,13 @@ services:
|
||||
- env.d/development/common
|
||||
- env.d/development/postgresql
|
||||
depends_on:
|
||||
- postgresql
|
||||
- redis
|
||||
- minio
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
redis:
|
||||
condition: service_started
|
||||
minio:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -116,11 +139,15 @@ services:
|
||||
volumes:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
- y-provider
|
||||
app-dev:
|
||||
condition: service_started
|
||||
y-provider:
|
||||
condition: service_started
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
frontend-dev:
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
@@ -135,9 +162,6 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
volumes:
|
||||
@@ -169,6 +193,11 @@ services:
|
||||
|
||||
kc_postgresql:
|
||||
image: postgres:14.3
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
ports:
|
||||
- "5433:5432"
|
||||
env_file:
|
||||
@@ -187,6 +216,13 @@ services:
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
@@ -200,4 +236,6 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
kc_postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
proxy_cache_path /tmp/auth_cache levels=1:2 keys_zone=auth_cache:10m inactive=60s max_size=100m;
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
server {
|
||||
listen 8083;
|
||||
server_name localhost;
|
||||
@@ -6,6 +5,14 @@ server {
|
||||
|
||||
# Proxy auth for collaboration server
|
||||
location /collaboration/ws/ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header 'Access-Control-Allow-Origin' 'http://localhost:3000';
|
||||
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Collaboration Auth request configuration
|
||||
auth_request /collaboration-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
@@ -34,6 +41,10 @@ server {
|
||||
}
|
||||
|
||||
location /collaboration-auth {
|
||||
proxy_cache auth_cache;
|
||||
proxy_cache_key "$http_authorization-$arg_room";
|
||||
proxy_cache_valid 200 30s;
|
||||
|
||||
proxy_pass http://app-dev:8000/api/v1.0/documents/collaboration-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -43,10 +54,11 @@ server {
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Accept "application/json";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location /collaboration/api/ {
|
||||
location /collaboration/api/ {
|
||||
# Collaboration server
|
||||
proxy_pass http://y-provider:4444;
|
||||
proxy_set_header Host $host;
|
||||
@@ -76,7 +88,7 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
@@ -88,5 +100,11 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Increase proxy buffer size to allow keycloak to send large
|
||||
# header responses when a user is created.
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Architecture Overview
|
||||
|
||||
This architecture showcases different ways for clients to interact with a **Hocus Pocus Server** (a [Y.js](https://github.com/yjs/yjs) provider) through either WebSockets, HTTP fallbacks, or Server-Sent Events (SSE) when WebSockets are not available.
|
||||
|
||||
**Main Components**:
|
||||
|
||||
- **Client**: The front-end application or user agent.
|
||||
- **Nginx**: A reverse proxy handling incoming requests, forwarding them to the appropriate services, and managing SSL/TLS termination if needed.
|
||||
- **Auth Sub Request (Django)**: Handles authentication/authorization, ensuring requests have valid credentials or permissions.
|
||||
- **Hocus Pocus Server**: The core collaborative editing server (powered by [Y.js](https://github.com/yjs/yjs) libraries) that manages document state and synchronization.
|
||||
- **Express**: Fallback server to handle push or pull requests when WebSocket connections fail.
|
||||
- **SSE**: A mechanism (Server-Sent Events) for real-time updates when WebSockets are unavailable.
|
||||
|
||||
## Mermaid Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
title1[WebSocket Success]-->Client1(Client)<--->|WebSocket Success|WS1(Websocket) --> Nginx1(Ngnix) <--> Auth1("Auth Sub Request (Django)") --->|With the good right|YServer1("Hocus Pocus Server")
|
||||
YServer1 --> WS1
|
||||
YServer1 <--> clients(Dispatch to clients)
|
||||
|
||||
title2[WebSocket Fails - Push data]-->Client2(Client)---|WebSocket fails|HTTP2(HTTP) --> Nginx2(Ngnix) <--> Auth2("Auth Sub Request (Django)")--->|With the good right|Express2(Express) --> YServer2("Hocus Pocus Server") --> clients(Dispatch to clients)
|
||||
|
||||
title3[WebSocket Fails - Pull data]-->Client3(Client)<--->|WebSocket fails|SSE(SSE) --> Nginx3(Ngnix) <--> Auth3("Auth Sub Request (Django)") --->|With the good right|Express3(Express) --> YServer3("Listen Hocus Pocus Server")
|
||||
YServer3("Listen Hocus Pocus Server") --> SSE
|
||||
YServer3("Listen Hocus Pocus Server") <--> clients(Data from clients)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Flows
|
||||
|
||||
### 1. WebSocket Success
|
||||
1. **Client** attempts a WebSocket connection.
|
||||
2. **Nginx** proxies the WebSocket connection through the **Auth Sub Request (Django)** for authentication.
|
||||
3. Once authenticated, traffic is routed to the **Hocus Pocus Server**.
|
||||
4. The server can broadcast data to all clients connected through WebSockets.
|
||||
- Note: The path `YServer1 --> WS1` indicates the two-way real-time communication between the server and client(s).
|
||||
|
||||
### 2. WebSocket Fails — Push Data (HTTP)
|
||||
If WebSocket connections fail, clients can **push** data via HTTP:
|
||||
1. **Client** detects WebSocket failure and falls back to sending data over **HTTP**.
|
||||
2. **Nginx** handles HTTP requests and authenticates them via the **Auth Sub Request (Django)**.
|
||||
3. After successful authentication, the requests go to an **Express** server.
|
||||
4. The **Express** server relays changes to the **Hocus Pocus Server**.
|
||||
5. The **Hocus Pocus Server** dispatches updated content to connected clients.
|
||||
|
||||
### 3. WebSocket Fails — Pull Data (SSE)
|
||||
For continuously receiving data when WebSockets fail, the client can **pull** data using SSE:
|
||||
1. **Client** sets up an **SSE** connection.
|
||||
2. **Nginx** proxies the SSE stream request through the **Auth Sub Request (Django)** for authentication.
|
||||
3. Once authenticated, the **Express** server listens to the **Hocus Pocus Server** for changes.
|
||||
4. The server then sends updates back to the **Client** through SSE in near real-time.
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| **Component** | **Responsibility** |
|
||||
|-----------------------------|-----------------------------------------------------------------------------------------|
|
||||
| **Client** | Initiates connections (WebSocket/HTTP/SSE), displays and interacts with data |
|
||||
| **Nginx** | Acts as a reverse proxy, routes traffic, handles SSL, and passes auth sub requests |
|
||||
| **Auth Sub Request (Django)** | Validates requests, ensuring correct permissions and tokens |
|
||||
| **WebSocket** | Real-time two-way communication channel |
|
||||
| **HTTP** | Fallback method for sending updates when WebSockets are not available |
|
||||
| **Express** | Fallback server for handling requests (push/pull of data) |
|
||||
| **SSE** | Mechanism for real-time one-way updates from server to client |
|
||||
| **Hocus Pocus Server** | Core Y.js server for collaboration, managing document states and synchronization |
|
||||
|
||||
---
|
||||
|
||||
## Why This Setup?
|
||||
|
||||
- **Reliability:** Ensures that when a user’s browser or network environment does not support WebSockets, there are fallback mechanisms (HTTP for push updates and SSE for server-initiated updates).
|
||||
- **Scalability:** Nginx can efficiently proxy requests and scale horizontally, while the authentication step is centralized in Django.
|
||||
- **Security:** The Auth Sub Request in Django enforces proper permissions before data is relayed to the collaboration server.
|
||||
- **Real-time Collaboration:** The Hocus Pocus Server provides low-latency updates, essential for collaborative editing, supported by [Y.js](https://github.com/yjs/yjs).
|
||||
|
||||
---
|
||||
|
||||
### Contributing
|
||||
If you have any suggestions or improvements, feel free to open an issue or submit a pull request.
|
||||
|
||||
**Thank you for exploring this architecture!** If you have any questions or need more detailed explanations, please let us know.
|
||||
@@ -1,166 +1,552 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-28 20:29
|
||||
|
||||
import uuid
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import timezone_field.fields
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import timezone_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
name="Document",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this document is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document',
|
||||
'verbose_name_plural': 'Documents',
|
||||
'db_table': 'impress_document',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
"db_table": "impress_document",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Template',
|
||||
name="Template",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('description', models.TextField(blank=True, verbose_name='description')),
|
||||
('code', models.TextField(blank=True, verbose_name='code')),
|
||||
('css', models.TextField(blank=True, verbose_name='css')),
|
||||
('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255, verbose_name="title")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(blank=True, verbose_name="description"),
|
||||
),
|
||||
("code", models.TextField(blank=True, verbose_name="code")),
|
||||
("css", models.TextField(blank=True, verbose_name="css")),
|
||||
(
|
||||
"is_public",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this template is public for anyone to use.",
|
||||
verbose_name="public",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template',
|
||||
'verbose_name_plural': 'Templates',
|
||||
'db_table': 'impress_template',
|
||||
'ordering': ('title',),
|
||||
"verbose_name": "Template",
|
||||
"verbose_name_plural": "Templates",
|
||||
"db_table": "impress_template",
|
||||
"ordering": ("title",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
name="User",
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sub",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.",
|
||||
regex="^[\\w.@+-]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
verbose_name="identity email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"admin_email",
|
||||
models.EmailField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="admin email address",
|
||||
),
|
||||
),
|
||||
(
|
||||
"language",
|
||||
models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
(
|
||||
"timezone",
|
||||
timezone_field.fields.TimeZoneField(
|
||||
choices_display="WITH_GMT_OFFSET",
|
||||
default="UTC",
|
||||
help_text="The timezone in which the user wants to see times.",
|
||||
use_pytz=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_device",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user is a device or a real user.",
|
||||
verbose_name="device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'db_table': 'impress_user',
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"db_table": "impress_user",
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentAccess',
|
||||
name="DocumentAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user relation',
|
||||
'verbose_name_plural': 'Document/user relations',
|
||||
'db_table': 'impress_document_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Document/user relation",
|
||||
"verbose_name_plural": "Document/user relations",
|
||||
"db_table": "impress_document_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invitation',
|
||||
name="Invitation",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('email', models.EmailField(max_length=254, verbose_name='email address')),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')),
|
||||
('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(max_length=254, verbose_name="email address"),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issuer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document invitation',
|
||||
'verbose_name_plural': 'Document invitations',
|
||||
'db_table': 'impress_invitation',
|
||||
"verbose_name": "Document invitation",
|
||||
"verbose_name_plural": "Document invitations",
|
||||
"db_table": "impress_invitation",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TemplateAccess',
|
||||
name="TemplateAccess",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)),
|
||||
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
("team", models.CharField(blank=True, max_length=100)),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("reader", "Reader"),
|
||||
("editor", "Editor"),
|
||||
("administrator", "Administrator"),
|
||||
("owner", "Owner"),
|
||||
],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"template",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="accesses",
|
||||
to="core.template",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Template/user relation',
|
||||
'verbose_name_plural': 'Template/user relations',
|
||||
'db_table': 'impress_template_access',
|
||||
'ordering': ('-created_at',),
|
||||
"verbose_name": "Template/user relation",
|
||||
"verbose_name_plural": "Template/user relations",
|
||||
"db_table": "impress_template_access",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "document"),
|
||||
name="unique_document_user",
|
||||
violation_error_message="This user is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "document"),
|
||||
name="unique_document_team",
|
||||
violation_error_message="This team is already in this document.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='documentaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="documentaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_document_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='invitation',
|
||||
constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'),
|
||||
model_name="invitation",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("email", "document"), name="email_and_document_unique_together"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("user__isnull", False)),
|
||||
fields=("user", "template"),
|
||||
name="unique_template_user",
|
||||
violation_error_message="This user is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("team__gt", "")),
|
||||
fields=("team", "template"),
|
||||
name="unique_template_team",
|
||||
violation_error_message="This team is already in this template.",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='templateaccess',
|
||||
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
model_name="templateaccess",
|
||||
constraint=models.CheckConstraint(
|
||||
check=models.Q(
|
||||
models.Q(("team", ""), ("user__isnull", False)),
|
||||
models.Q(("team__gt", ""), ("user__isnull", True)),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_template_access_either_user_or_team",
|
||||
violation_error_message="Either user or team must be set, not both.",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
("core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
+86
-24
@@ -1,52 +1,114 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 16:55
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
("core", "0002_create_pg_trgm_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="authenticated",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='link_role',
|
||||
field=models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor')], default='reader', max_length=20),
|
||||
model_name="document",
|
||||
name="link_role",
|
||||
field=models.CharField(
|
||||
choices=[("reader", "Reader"), ("editor", "Editor")],
|
||||
default="reader",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='is_public',
|
||||
model_name="document",
|
||||
name="is_public",
|
||||
field=models.BooleanField(null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LinkTrace',
|
||||
name="LinkTrace",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='link_traces', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="link_traces",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document/user link trace',
|
||||
'verbose_name_plural': 'Document/user link traces',
|
||||
'db_table': 'impress_link_trace',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_link_trace_document_user', violation_error_message='A link trace already exists for this document/user.')],
|
||||
"verbose_name": "Document/user link trace",
|
||||
"verbose_name_plural": "Document/user link traces",
|
||||
"db_table": "impress_link_trace",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_link_trace_document_user",
|
||||
violation_error_message="A link trace already exists for this document/user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# Generated by Django 5.1 on 2024-09-08 17:04
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_is_public_to_link_reach(apps, schema_editor):
|
||||
"""
|
||||
Forward migration: Migrate 'is_public' to 'link_reach'.
|
||||
If is_public == True, set link_reach to 'public'
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(is_public=True).update(link_reach='public')
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(is_public=True).update(link_reach="public")
|
||||
|
||||
|
||||
def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
@@ -16,20 +17,20 @@ def reverse_migrate_link_reach_to_is_public(apps, schema_editor):
|
||||
- If link_reach == 'public', set is_public to True
|
||||
- Else set is_public to False
|
||||
"""
|
||||
Document = apps.get_model('core', 'Document')
|
||||
Document.objects.filter(link_reach='public').update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=['restricted', "authenticated"]).update(is_public=False)
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(link_reach="public").update(is_public=True)
|
||||
Document.objects.filter(link_reach__in=["restricted", "authenticated"]).update(
|
||||
is_public=False
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_document_link_reach_document_link_role_and_more'),
|
||||
("core", "0003_document_link_reach_document_link_role_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
migrate_is_public_to_link_reach,
|
||||
reverse_migrate_link_reach_to_is_public
|
||||
migrate_is_public_to_link_reach, reverse_migrate_link_reach_to_is_public
|
||||
),
|
||||
]
|
||||
|
||||
+6
-5
@@ -4,15 +4,16 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_migrate_is_public_to_link_reach'),
|
||||
("core", "0004_migrate_is_public_to_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='title'),
|
||||
model_name="document",
|
||||
name="title",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=255, null=True, verbose_name="title"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,25 +4,34 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
|
||||
("core", "0005_remove_document_is_public_alter_document_link_reach_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
model_name="user",
|
||||
name="full_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=100, null=True, verbose_name="full name"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=20, null=True, verbose_name='short name'),
|
||||
model_name="user",
|
||||
name="short_name",
|
||||
field=models.CharField(
|
||||
blank=True, max_length=20, null=True, verbose_name="short name"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -117,10 +117,10 @@ BEGIN
|
||||
END $$;
|
||||
"""
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0006_add_user_full_name_and_short_name'),
|
||||
("core", "0006_add_user_full_name_and_short_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,15 +4,22 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_fix_users_duplicate'),
|
||||
("core", "0007_fix_users_duplicate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='link_reach',
|
||||
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
|
||||
model_name="document",
|
||||
name="link_reach",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("restricted", "Restricted"),
|
||||
("authenticated", "Authenticated"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="restricted",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,37 +1,87 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-08 07:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_alter_document_link_reach'),
|
||||
("core", "0008_alter_document_link_reach"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentFavorite',
|
||||
name="DocumentFavorite",
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by_users', to='core.document')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_documents', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
help_text="primary key for the record as UUID",
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="id",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="date and time at which a record was created",
|
||||
verbose_name="created on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="date and time at which a record was last updated",
|
||||
verbose_name="updated on",
|
||||
),
|
||||
),
|
||||
(
|
||||
"document",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorited_by_users",
|
||||
to="core.document",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="favorite_documents",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document favorite',
|
||||
'verbose_name_plural': 'Document favorites',
|
||||
'db_table': 'impress_document_favorite',
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'document'), name='unique_document_favorite_user', violation_error_message='This document is already targeted by a favorite relation instance for the same user.')],
|
||||
"verbose_name": "Document favorite",
|
||||
"verbose_name_plural": "Document favorites",
|
||||
"db_table": "impress_document_favorite",
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "document"),
|
||||
name="unique_document_favorite_user",
|
||||
violation_error_message="This document is already targeted by a favorite relation instance for the same user.",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,25 +7,48 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_add_document_favorite'),
|
||||
("core", "0009_add_document_favorite"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='sub',
|
||||
field=models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.', regex='^[\\w.@+-:]+\\Z')], verbose_name='sub'),
|
||||
model_name="user",
|
||||
name="sub",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only.",
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
message="Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters.",
|
||||
regex="^[\\w.@+-:]+\\Z",
|
||||
)
|
||||
],
|
||||
verbose_name="sub",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
from django.db.models import F, ForeignKey, Subquery, OuterRef, Q
|
||||
from django.db.models import F, ForeignKey, OuterRef, Q, Subquery
|
||||
|
||||
|
||||
def set_creator_from_document_access(apps, schema_editor):
|
||||
@@ -25,28 +25,37 @@ def set_creator_from_document_access(apps, schema_editor):
|
||||
DocumentAccess = apps.get_model("core", "DocumentAccess")
|
||||
|
||||
# Update `creator` using the "owner" role
|
||||
owner_subquery = DocumentAccess.objects.filter(
|
||||
document=OuterRef('pk'),
|
||||
user__isnull=False,
|
||||
role='owner',
|
||||
).order_by('created_at').values('user_id')[:1]
|
||||
owner_subquery = (
|
||||
DocumentAccess.objects.filter(
|
||||
document=OuterRef("pk"),
|
||||
user__isnull=False,
|
||||
role="owner",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.values("user_id")[:1]
|
||||
)
|
||||
|
||||
Document.objects.filter(
|
||||
creator__isnull=True
|
||||
).update(creator=Subquery(owner_subquery))
|
||||
Document.objects.filter(creator__isnull=True).update(
|
||||
creator=Subquery(owner_subquery)
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_field_creator_to_document'),
|
||||
("core", "0010_add_field_creator_to_document"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_creator_from_document_access, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_creator_from_document_access, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=ForeignKey(
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
+28
-11
@@ -6,25 +6,42 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_populate_creator_field_and_make_it_required'),
|
||||
("core", "0011_populate_creator_field_and_make_it_required"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='creator',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='documents_created', to=settings.AUTH_USER_MODEL),
|
||||
model_name="document",
|
||||
name="creator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.RESTRICT,
|
||||
related_name="documents_created",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitation',
|
||||
name='issuer',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL),
|
||||
model_name="invitation",
|
||||
name="issuer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="invitations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
|
||||
("core", "0012_make_document_creator_and_invitation_issuer_optional"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,28 +4,29 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_activate_fuzzystrmatch_extension'),
|
||||
("core", "0013_activate_fuzzystrmatch_extension"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='depth',
|
||||
model_name="document",
|
||||
name="depth",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='numchild',
|
||||
model_name="document",
|
||||
name="numchild",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
model_name="document",
|
||||
name="path",
|
||||
# Allow null values pending the next datamigration to populate the field
|
||||
field=models.CharField(db_collation='C', max_length=252, null=True, unique=True),
|
||||
field=models.CharField(
|
||||
db_collation="C", max_length=252, null=True, unique=True
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,9 +7,10 @@ from treebeard.numconv import NumConv
|
||||
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
STEPLEN = 7
|
||||
|
||||
|
||||
def set_path_on_existing_documents(apps, schema_editor):
|
||||
"""
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
Updates the `path` and `depth` fields for all existing Document records
|
||||
to ensure valid materialized paths.
|
||||
|
||||
This function assigns a unique `path` to each Document as a root node
|
||||
@@ -26,27 +27,25 @@ def set_path_on_existing_documents(apps, schema_editor):
|
||||
updates = []
|
||||
for i, pk in enumerate(documents):
|
||||
key = numconv.int2str(i)
|
||||
path = "{0}{1}".format(
|
||||
ALPHABET[0] * (STEPLEN - len(key)),
|
||||
key
|
||||
)
|
||||
path = "{0}{1}".format(ALPHABET[0] * (STEPLEN - len(key)), key)
|
||||
updates.append(Document(pk=pk, path=path, depth=1))
|
||||
|
||||
# Bulk update using the prepared updates list
|
||||
Document.objects.bulk_update(updates, ['depth', 'path'])
|
||||
Document.objects.bulk_update(updates, ["depth", "path"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_add_tree_structure_to_documents'),
|
||||
("core", "0014_add_tree_structure_to_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_path_on_existing_documents, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RunPython(
|
||||
set_path_on_existing_documents, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='document',
|
||||
name='path',
|
||||
field=models.CharField(db_collation='C', max_length=252, unique=True),
|
||||
model_name="document",
|
||||
name="path",
|
||||
field=models.CharField(db_collation="C", max_length=252, unique=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,20 +4,27 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_set_path_on_existing_documents'),
|
||||
("core", "0015_set_path_on_existing_documents"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='excerpt',
|
||||
field=models.TextField(blank=True, max_length=300, null=True, verbose_name='excerpt'),
|
||||
model_name="document",
|
||||
name="excerpt",
|
||||
field=models.TextField(
|
||||
blank=True, max_length=300, null=True, verbose_name="excerpt"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,33 +4,49 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_add_document_excerpt'),
|
||||
("core", "0016_add_document_excerpt"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='document',
|
||||
options={'ordering': ('path',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'},
|
||||
name="document",
|
||||
options={
|
||||
"ordering": ("path",),
|
||||
"verbose_name": "Document",
|
||||
"verbose_name_plural": "Documents",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='ancestors_deleted_at',
|
||||
model_name="document",
|
||||
name="ancestors_deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='deleted_at',
|
||||
model_name="document",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
model_name="user",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices="(('en-us', 'English'), ('fr-fr', 'French'), ('de-de', 'German'))",
|
||||
default="en-us",
|
||||
help_text="The language in which the user wants to see the interface.",
|
||||
max_length=10,
|
||||
verbose_name="language",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='document',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('deleted_at__isnull', True), ('deleted_at', models.F('ancestors_deleted_at')), _connector='OR'), name='check_deleted_at_matches_ancestors_deleted_at_when_set'),
|
||||
model_name="document",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("deleted_at__isnull", True),
|
||||
("deleted_at", models.F("ancestors_deleted_at")),
|
||||
_connector="OR",
|
||||
),
|
||||
name="check_deleted_at_matches_ancestors_deleted_at_when_set",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_titles_to_null(apps, schema_editor):
|
||||
"""
|
||||
If the titles are "Untitled document" or "Unbenanntes Dokument" or "Document sans titre"
|
||||
we set them to Null
|
||||
"""
|
||||
Document = apps.get_model("core", "Document")
|
||||
Document.objects.filter(
|
||||
title__in=["Untitled document", "Unbenanntes Dokument", "Document sans titre"]
|
||||
).update(title=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0017_add_fields_for_soft_delete"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
update_titles_to_null, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_blank_title_migration(migrator):
|
||||
"""
|
||||
Test that the migration fixes the titles of documents that are
|
||||
"Untitled document", "Unbenanntes Dokument" or "Document sans titre"
|
||||
"""
|
||||
migrator.apply_initial_migration(("core", "0017_add_fields_for_soft_delete"))
|
||||
|
||||
english_doc = factories.DocumentFactory(title="Untitled document")
|
||||
german_doc = factories.DocumentFactory(title="Unbenanntes Dokument")
|
||||
french_doc = factories.DocumentFactory(title="Document sans titre")
|
||||
other_doc = factories.DocumentFactory(title="My document")
|
||||
|
||||
assert english_doc.title == "Untitled document"
|
||||
assert german_doc.title == "Unbenanntes Dokument"
|
||||
assert french_doc.title == "Document sans titre"
|
||||
assert other_doc.title == "My document"
|
||||
|
||||
# Apply the migration
|
||||
migrator.apply_tested_migration(("core", "0018_update_blank_title"))
|
||||
|
||||
english_doc.refresh_from_db()
|
||||
german_doc.refresh_from_db()
|
||||
french_doc.refresh_from_db()
|
||||
other_doc.refresh_from_db()
|
||||
|
||||
assert english_doc.title == None
|
||||
assert german_doc.title == None
|
||||
assert french_doc.title == None
|
||||
assert other_doc.title == "My document"
|
||||
@@ -68,6 +68,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"django-test-migrations==1.4.0",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
@@ -99,7 +100,6 @@ exclude = [
|
||||
"build",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"*/migrations/*",
|
||||
]
|
||||
line-length = 88
|
||||
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import { test as setup } from '@playwright/test';
|
||||
import { FullConfig, FullProject, chromium, expect } from '@playwright/test';
|
||||
|
||||
import { keyCloakSignIn } from './common';
|
||||
|
||||
setup('authenticate-chromium', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'chromium');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-chromium.json` });
|
||||
});
|
||||
const saveStorageState = async (
|
||||
browserConfig: FullProject<unknown, unknown>,
|
||||
) => {
|
||||
const browserName = browserConfig?.name || 'chromium';
|
||||
|
||||
setup('authenticate-webkit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'webkit');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-webkit.json` });
|
||||
});
|
||||
const { storageState, ...useConfig } = browserConfig?.use;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext(useConfig);
|
||||
const page = await context.newPage();
|
||||
|
||||
setup('authenticate-firefox', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, 'firefox');
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: `playwright/.auth/user-firefox.json` });
|
||||
});
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await page.content();
|
||||
await expect(page.getByText('Docs').first()).toBeVisible();
|
||||
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
await expect(
|
||||
page.locator('header').first().getByRole('button', {
|
||||
name: 'Logout',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.context().storageState({
|
||||
path: storageState as string,
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
const chromeConfig = config.projects.find((p) => p.name === 'chromium')!;
|
||||
const firefoxConfig = config.projects.find((p) => p.name === 'firefox')!;
|
||||
const webkitConfig = config.projects.find((p) => p.name === 'webkit')!;
|
||||
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
await saveStorageState(chromeConfig);
|
||||
await saveStorageState(webkitConfig);
|
||||
await saveStorageState(firefoxConfig);
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Collaboration', () => {
|
||||
/**
|
||||
* We check:
|
||||
* - connection to the collaborative server
|
||||
* - signal of the backend to the collaborative server (connection should close)
|
||||
* - reconnection to the collaborative server
|
||||
*/
|
||||
test('checks the connection with collaborative server', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
const [title] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await verifyDocName(page, title);
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
'ws://localhost:8083/collaboration/ws/?room=',
|
||||
);
|
||||
|
||||
// Is connected
|
||||
let framesentPromise = webSocket.waitForEvent('framesent');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
let framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
// When the visibility is changed, the ws should closed the connection (backend signal)
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Checkt the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
webSocket = await webSocketPromise;
|
||||
framesentPromise = webSocket.waitForEvent('framesent');
|
||||
framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
test('checks the connection switch to polling after websocket failure', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/poll/') && response.status() === 200,
|
||||
);
|
||||
|
||||
await page.routeWebSocket(
|
||||
'ws://localhost:8083/collaboration/ws/**',
|
||||
async (ws) => {
|
||||
await ws.close();
|
||||
},
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
|
||||
await createDoc(page, 'doc-polling', browserName, 1);
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -88,70 +88,6 @@ test.describe('Doc Editor', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* We check:
|
||||
* - connection to the collaborative server
|
||||
* - signal of the backend to the collaborative server (connection should close)
|
||||
* - reconnection to the collaborative server
|
||||
*/
|
||||
test('checks the connection with collaborative server', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
'ws://localhost:8083/collaboration/ws/?room=',
|
||||
);
|
||||
|
||||
// Is connected
|
||||
let framesentPromise = webSocket.waitForEvent('framesent');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
let framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
// When the visibility is changed, the ws should closed the connection (backend signal)
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
expect(wsClose.isClosed()).toBeTruthy();
|
||||
|
||||
// Checkt the ws is connected again
|
||||
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
|
||||
return webSocket
|
||||
.url()
|
||||
.includes('ws://localhost:8083/collaboration/ws/?room=');
|
||||
});
|
||||
|
||||
webSocket = await webSocketPromise;
|
||||
framesentPromise = webSocket.waitForEvent('framesent');
|
||||
framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
});
|
||||
|
||||
test('markdown button converts from markdown to the editor syntax json', async ({
|
||||
page,
|
||||
browserName,
|
||||
|
||||
@@ -88,6 +88,7 @@ test.describe('Header mobile', () => {
|
||||
test.describe('Header: Log out', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test('checks logout button', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
@@ -38,10 +38,9 @@ export default defineConfig({
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
|
||||
globalSetup: require.resolve('./__tests__/app-impress/auth.setup'),
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
@@ -53,7 +52,6 @@ export default defineConfig({
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
@@ -63,7 +61,6 @@ export default defineConfig({
|
||||
timezoneId: 'Europe/Paris',
|
||||
storageState: 'playwright/.auth/user-webkit.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
@@ -79,7 +76,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-media/react-pdf-table": "2.0.1",
|
||||
"@blocknote/core": "0.23.2",
|
||||
"@blocknote/mantine": "0.23.2",
|
||||
"@blocknote/react": "0.23.2",
|
||||
@@ -61,6 +62,7 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "*",
|
||||
"@types/ws": "8.5.13",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
<svg
|
||||
width="32"
|
||||
height="33"
|
||||
viewBox="0 0 32 33"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M21.6305 29.5812C22.7983 29.2538 23.9166 28.6562 24.6505 27.6003C25.3749 26.5663 25.5789 25.2547 25.5789 23.9925V5.50099C25.5789 5.17358 25.5611 4.84557 25.5216 4.52148C26.1016 4.74961 26.5486 5.12658 26.8626 5.65239C27.2331 6.25024 27.4184 7.03757 27.4184 8.01435V26.7964C27.4184 28.1184 27.0942 29.1078 26.4458 29.7646C25.7974 30.4214 24.8207 30.7498 23.5155 30.7498H16.4209C16.5889 30.7204 16.7574 30.6901 16.9262 30.659C18.4067 30.3944 19.9713 30.0354 21.6185 29.5846L21.6305 29.5812Z"
|
||||
fill="#C9191E"
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -2,12 +2,10 @@ import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/features/docs/doc-management/';
|
||||
import { toBase64, useUpdateDoc } from '@/features/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
|
||||
import { isFirefox } from '@/utils/userAgent';
|
||||
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
|
||||
@@ -25,6 +25,7 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: -17px;
|
||||
left: 0px;
|
||||
padding: 0px 6px;
|
||||
border-radius: 0px;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -22,6 +22,3 @@ function hslToHex(h: number, s: number, l: number) {
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
export const toBase64 = (str: Uint8Array) =>
|
||||
Buffer.from(str).toString('base64');
|
||||
|
||||
@@ -57,16 +57,13 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const { toast } = useToastProvider();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isUntitled = titleDisplay === untitledDocument;
|
||||
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(data) {
|
||||
if (data.title !== untitledDocument) {
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
}
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${data.id}`);
|
||||
@@ -80,8 +77,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
|
||||
// When blank we set to untitled
|
||||
if (!sanitizedTitle) {
|
||||
sanitizedTitle = untitledDocument;
|
||||
setTitleDisplay(sanitizedTitle);
|
||||
setTitleDisplay('');
|
||||
}
|
||||
|
||||
// If mutation we update
|
||||
@@ -90,7 +86,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
updateDoc({ id: doc.id, title: sanitizedTitle });
|
||||
}
|
||||
},
|
||||
[doc.id, doc.title, untitledDocument, updateDoc],
|
||||
[doc.id, doc.title, updateDoc],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -111,7 +107,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
as="span"
|
||||
role="textbox"
|
||||
contentEditable
|
||||
defaultValue={isUntitled ? undefined : titleDisplay}
|
||||
defaultValue={titleDisplay || undefined}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
aria-label="doc title input"
|
||||
@@ -135,7 +131,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
outline: none;
|
||||
`}
|
||||
>
|
||||
{isUntitled ? '' : titleDisplay}
|
||||
{titleDisplay}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { Text as PDFText, pdf } from '@react-pdf/renderer';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -27,6 +27,8 @@ import { Doc } from '@/features/docs/doc-management';
|
||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
import { downloadFile, exportResolveFileUrl } from '../utils';
|
||||
|
||||
import { Table } from './blocks/Table';
|
||||
|
||||
enum DocDownloadFormat {
|
||||
PDF = 'pdf',
|
||||
DOCX = 'docx',
|
||||
@@ -113,7 +115,8 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
? 1.5
|
||||
: 1.17;
|
||||
return (
|
||||
<Text
|
||||
<PDFText
|
||||
key={block.id}
|
||||
style={{
|
||||
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
|
||||
fontWeight: 700,
|
||||
@@ -122,7 +125,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
}}
|
||||
>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
paragraph: (block, exporter) => {
|
||||
@@ -146,11 +149,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Text key={block.id}>
|
||||
<PDFText key={block.id}>
|
||||
{exporter.transformInlineContent(block.content)}
|
||||
</Text>
|
||||
</PDFText>
|
||||
);
|
||||
},
|
||||
table: (block, transformer) => {
|
||||
return <Table data={block.content} transformer={transformer} />;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { TD, TH, TR, Table as TablePDF } from '@ag-media/react-pdf-table';
|
||||
import {
|
||||
DefaultBlockSchema,
|
||||
Exporter,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
TableContent,
|
||||
} from '@blocknote/core';
|
||||
import { View } from '@react-pdf/renderer';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export const Table = (props: {
|
||||
data: TableContent<InlineContentSchema>;
|
||||
transformer: Exporter<
|
||||
DefaultBlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown,
|
||||
unknown
|
||||
>;
|
||||
}) => {
|
||||
return (
|
||||
<TablePDF>
|
||||
{props.data.rows.map((row, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<TH key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
{props.transformer.transformInlineContent(cell)}
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TH>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TR key={index}>
|
||||
{row.cells.map((cell, index) => {
|
||||
// Make empty cells are rendered.
|
||||
if (cell.length === 0) {
|
||||
cell.push({
|
||||
styles: {},
|
||||
text: ' ',
|
||||
type: 'text',
|
||||
});
|
||||
}
|
||||
return (
|
||||
<TD key={index}>
|
||||
<View>
|
||||
{
|
||||
props.transformer.transformInlineContent(
|
||||
cell,
|
||||
) as ReactNode
|
||||
}
|
||||
</View>
|
||||
</TD>
|
||||
);
|
||||
})}
|
||||
</TR>
|
||||
);
|
||||
})}
|
||||
</TablePDF>
|
||||
);
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { APIError, errorCauses } from '@/api';
|
||||
|
||||
interface PollOutgoingMessageParams {
|
||||
pollUrl: string;
|
||||
message64: string;
|
||||
}
|
||||
interface PollOutgoingMessageResponse {
|
||||
updated?: boolean;
|
||||
}
|
||||
|
||||
export const pollOutgoingMessageRequest = async ({
|
||||
pollUrl,
|
||||
message64,
|
||||
}: PollOutgoingMessageParams): Promise<PollOutgoingMessageResponse> => {
|
||||
const response = await fetch(pollUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message64,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Post poll message request failed`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PollOutgoingMessageResponse>;
|
||||
};
|
||||
|
||||
interface PollSyncParams {
|
||||
pollUrl: string;
|
||||
localDoc64: string;
|
||||
}
|
||||
interface PollSyncResponse {
|
||||
syncDoc64?: string;
|
||||
}
|
||||
|
||||
export const postPollSyncRequest = async ({
|
||||
pollUrl,
|
||||
localDoc64,
|
||||
}: PollSyncParams): Promise<PollSyncResponse> => {
|
||||
const response = await fetch(pollUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
localDoc64,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
`Sync request failed: ${response.status} ${response.statusText}`,
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PollSyncResponse>;
|
||||
};
|
||||
@@ -6,14 +6,9 @@ import { Doc } from '../types';
|
||||
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
|
||||
export type CreateDocParam = Pick<Doc, 'title'>;
|
||||
|
||||
export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
|
||||
export const createDoc = async (): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -29,7 +24,7 @@ interface CreateDocProps {
|
||||
|
||||
export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError, CreateDocParam>({
|
||||
return useMutation<Doc, APIError>({
|
||||
mutationFn: createDoc,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.resetQueries({
|
||||
|
||||
+16
-3
@@ -6,17 +6,29 @@ import { useBroadcastStore } from '@/stores';
|
||||
import { useProviderStore } from '../stores/useProviderStore';
|
||||
import { Base64 } from '../types';
|
||||
|
||||
export const useCollaboration = (room?: string, initialContent?: Base64) => {
|
||||
export const useCollaboration = (
|
||||
room?: string,
|
||||
initialContent?: Base64,
|
||||
canEdit?: boolean,
|
||||
) => {
|
||||
const collaborationUrl = useCollaborationUrl(room);
|
||||
const { setBroadcastProvider } = useBroadcastStore();
|
||||
const { provider, createProvider, destroyProvider } = useProviderStore();
|
||||
|
||||
/**
|
||||
* Initialize the provider
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!room || !collaborationUrl || provider) {
|
||||
if (!room || !collaborationUrl || provider || canEdit === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newProvider = createProvider(collaborationUrl, room, initialContent);
|
||||
const newProvider = createProvider(
|
||||
collaborationUrl,
|
||||
room,
|
||||
canEdit,
|
||||
initialContent,
|
||||
);
|
||||
setBroadcastProvider(newProvider);
|
||||
}, [
|
||||
provider,
|
||||
@@ -25,6 +37,7 @@ export const useCollaboration = (room?: string, initialContent?: Base64) => {
|
||||
initialContent,
|
||||
createProvider,
|
||||
setBroadcastProvider,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import {
|
||||
CompleteHocuspocusProviderConfiguration,
|
||||
CompleteHocuspocusProviderWebsocketConfiguration,
|
||||
HocuspocusProvider,
|
||||
HocuspocusProviderConfiguration,
|
||||
WebSocketStatus,
|
||||
onOutgoingMessageParameters,
|
||||
onStatusParameters,
|
||||
} from '@hocuspocus/provider';
|
||||
import type { MessageEvent } from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { isAPIError } from '@/api';
|
||||
|
||||
import {
|
||||
pollOutgoingMessageRequest,
|
||||
postPollSyncRequest,
|
||||
} from '../api/collaborationRequests';
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
type HocuspocusProviderConfigurationUrl = Required<
|
||||
Pick<CompleteHocuspocusProviderConfiguration, 'name'>
|
||||
> &
|
||||
Partial<CompleteHocuspocusProviderConfiguration> &
|
||||
Required<Pick<CompleteHocuspocusProviderWebsocketConfiguration, 'url'>>;
|
||||
|
||||
export const isHocuspocusProviderConfigurationUrl = (
|
||||
data: HocuspocusProviderConfiguration,
|
||||
): data is HocuspocusProviderConfigurationUrl => {
|
||||
return 'url' in data;
|
||||
};
|
||||
|
||||
type CollaborationProviderConfiguration = HocuspocusProviderConfiguration & {
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export class CollaborationProvider extends HocuspocusProvider {
|
||||
/**
|
||||
* If the user can edit the document
|
||||
*/
|
||||
public canEdit = false;
|
||||
|
||||
/**
|
||||
* If the long polling is started
|
||||
* it is used to avoid starting it multiple times
|
||||
* when the websocket is failed.
|
||||
*/
|
||||
public isLongPollingStarted = false;
|
||||
|
||||
/**
|
||||
* If the document is syncing with the server
|
||||
* it is used to avoid starting it multiple times.
|
||||
*/
|
||||
public isSyncing = false;
|
||||
/**
|
||||
* The document can pass out of sync
|
||||
* then sync again with a next updates so
|
||||
* we add a counter to avoid syncing the document
|
||||
* to quickly.
|
||||
*/
|
||||
public seemsUnsyncCount = 0;
|
||||
public seemsUnsyncMaxCount = 5;
|
||||
|
||||
/**
|
||||
* In Safari or Firefox the websocket takes time before passing in
|
||||
* mode failed, it can takes up to 1 minutes. To avoid this latence
|
||||
* we set isWebsocketFailed to true, it is connects it will switch
|
||||
* to false.
|
||||
*/
|
||||
public isWebsocketFailed = true;
|
||||
/**
|
||||
* There is a ping-pong mechanism with awareness, receipt awareness is send again,
|
||||
* it creates useless requests.
|
||||
* We use this variable to avoid treating the same awareness message twice.
|
||||
*/
|
||||
private treatedAwarenessMessage: string | null = null;
|
||||
/**
|
||||
* Server-Sent Events
|
||||
*/
|
||||
protected sse: EventSource | null = null;
|
||||
/**
|
||||
* Polling timeout
|
||||
* It is used to avoid starting the polling to quickly
|
||||
* to let the class init properly.
|
||||
*/
|
||||
protected pollTimeout: NodeJS.Timeout | null = null;
|
||||
/**
|
||||
* Easy way to get the url of the server
|
||||
*/
|
||||
protected url = '';
|
||||
|
||||
public constructor(configuration: CollaborationProviderConfiguration) {
|
||||
let url = '';
|
||||
if (isHocuspocusProviderConfigurationUrl(configuration)) {
|
||||
url = configuration.url;
|
||||
let withWS = true;
|
||||
if (
|
||||
new URLSearchParams(window.location.search).get('withoutWS') === 'true'
|
||||
) {
|
||||
withWS = false;
|
||||
}
|
||||
|
||||
configuration.url = !withWS ? 'ws://localhost:6666' : configuration.url;
|
||||
}
|
||||
|
||||
super(configuration);
|
||||
|
||||
this.url = url;
|
||||
this.canEdit = configuration.canEdit;
|
||||
|
||||
if (configuration.canEdit) {
|
||||
this.on('outgoingMessage', this.onPollOutgoingMessage.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
public setPollDefaultValues(): void {
|
||||
this.isLongPollingStarted = false;
|
||||
this.isWebsocketFailed = false;
|
||||
this.seemsUnsyncCount = 0;
|
||||
this.sse?.close();
|
||||
this.sse = null;
|
||||
if (this.pollTimeout) {
|
||||
clearTimeout(this.pollTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
super.destroy();
|
||||
this.setPollDefaultValues();
|
||||
}
|
||||
|
||||
public onStatus({ status }: onStatusParameters) {
|
||||
if (status === WebSocketStatus.Connecting) {
|
||||
this.isWebsocketFailed = true;
|
||||
if (this.pollTimeout) {
|
||||
clearTimeout(this.pollTimeout);
|
||||
}
|
||||
this.pollTimeout = setTimeout(() => {
|
||||
this.initPolling();
|
||||
}, 5000);
|
||||
} else if (status === WebSocketStatus.Connected) {
|
||||
this.setPollDefaultValues();
|
||||
}
|
||||
|
||||
super.onStatus({ status });
|
||||
}
|
||||
|
||||
public initPolling() {
|
||||
if (this.isLongPollingStarted || !this.isWebsocketFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLongPollingStarted = true;
|
||||
void this.pollSync(true);
|
||||
this.initCollaborationSSE();
|
||||
}
|
||||
|
||||
protected toPollUrl(endpoint: string): string {
|
||||
let pollUrl = this.url.replace('ws:', 'http:');
|
||||
if (pollUrl.includes('wss:')) {
|
||||
pollUrl = pollUrl.replace('wss:', 'https:');
|
||||
}
|
||||
|
||||
pollUrl = pollUrl.replace('/ws/', '/ws/poll/' + endpoint + '/');
|
||||
|
||||
// To have our requests not cached
|
||||
return `${pollUrl}&${Date.now()}`;
|
||||
}
|
||||
|
||||
protected isDuplicateAwareness(message64: string): boolean {
|
||||
if (this.treatedAwarenessMessage === message64) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.treatedAwarenessMessage = message64;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outgoing message event
|
||||
*
|
||||
* Sent to the server the message to
|
||||
* be sent to the other users
|
||||
*/
|
||||
public async onPollOutgoingMessage({ message }: onOutgoingMessageParameters) {
|
||||
if (!this.isWebsocketFailed || !this.canEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message64 = Buffer.from(message.toUint8Array()).toString('base64');
|
||||
if (this.isDuplicateAwareness(message64)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { updated } = await pollOutgoingMessageRequest({
|
||||
pollUrl: this.toPollUrl('message'),
|
||||
message64,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
await this.pollSync();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isAPIError(error)) {
|
||||
// The user is not allowed to send messages
|
||||
if (error.status === 403) {
|
||||
this.off('outgoingMessage', this.onPollOutgoingMessage.bind(this));
|
||||
this.canEdit = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EventSource is a API for opening an HTTP
|
||||
* connection for receiving push notifications
|
||||
* from a server in real-time.
|
||||
* We use it to sync the document with the server
|
||||
*/
|
||||
protected initCollaborationSSE() {
|
||||
if (!this.isWebsocketFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sse = new EventSource(this.toPollUrl('message'), {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
this.sse.onmessage = (event) => {
|
||||
const { updatedDoc64, stateFingerprint, awareness64 } = JSON.parse(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
event.data,
|
||||
) as {
|
||||
updatedDoc64?: string;
|
||||
stateFingerprint?: string;
|
||||
awareness64?: string;
|
||||
};
|
||||
|
||||
if (awareness64) {
|
||||
if (this.isDuplicateAwareness(awareness64)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.treatedAwarenessMessage = awareness64;
|
||||
const awareness = Buffer.from(awareness64, 'base64');
|
||||
|
||||
this.onMessage({
|
||||
data: awareness,
|
||||
} as MessageEvent);
|
||||
}
|
||||
|
||||
if (updatedDoc64) {
|
||||
this.document.transact(() => {
|
||||
Y.applyUpdate(this.document, Buffer.from(updatedDoc64, 'base64'));
|
||||
}, this);
|
||||
}
|
||||
|
||||
const localStateFingerprint = this.getStateFingerprint(this.document);
|
||||
if (localStateFingerprint !== stateFingerprint) {
|
||||
void this.pollSync();
|
||||
} else {
|
||||
this.seemsUnsyncCount = 0;
|
||||
}
|
||||
};
|
||||
|
||||
this.sse.onopen = () => {};
|
||||
|
||||
this.sse.onerror = (err) => {
|
||||
console.error('SSE error:', err);
|
||||
this.sse?.close();
|
||||
|
||||
setTimeout(() => {
|
||||
this.initCollaborationSSE();
|
||||
}, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the document with the server.
|
||||
*
|
||||
* In some rare cases, the document may be out of sync.
|
||||
* We use a fingerprint to compare documents,
|
||||
* it happens that the local fingerprint is different from the server one
|
||||
* when awareness plus the document are updated quickly.
|
||||
* The system is resilient to this kind of problems, so `seemsUnsyncCount` should
|
||||
* go back to 0 after a few seconds. If not, we will force a sync.
|
||||
*/
|
||||
public async pollSync(forseSync = false) {
|
||||
if (!this.isWebsocketFailed || this.isSyncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seemsUnsyncCount++;
|
||||
|
||||
if (this.seemsUnsyncCount < this.seemsUnsyncMaxCount && !forseSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
const { syncDoc64 } = await postPollSyncRequest({
|
||||
pollUrl: this.toPollUrl('sync'),
|
||||
localDoc64: toBase64(Y.encodeStateAsUpdate(this.document)),
|
||||
});
|
||||
|
||||
if (syncDoc64) {
|
||||
const uint8Array = Buffer.from(syncDoc64, 'base64');
|
||||
Y.applyUpdate(this.document, uint8Array);
|
||||
this.seemsUnsyncCount = 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling sync failed:', error);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash SHA-256 of the state vector of the document.
|
||||
* Usefull to compare the state of the document.
|
||||
* @param doc
|
||||
* @returns
|
||||
*/
|
||||
public getStateFingerprint(doc: Y.Doc): string {
|
||||
const stateVector = Y.encodeStateVector(doc);
|
||||
return crypto.createHash('sha256').update(stateVector).digest('base64');
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { WebSocketStatus } from '@hocuspocus/provider';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
if (typeof EventSource === 'undefined') {
|
||||
const mockEventSource = jest.fn();
|
||||
class MockEventSource {
|
||||
constructor(...args: any[]) {
|
||||
return mockEventSource(...args);
|
||||
}
|
||||
}
|
||||
(global as any).EventSource = MockEventSource;
|
||||
}
|
||||
|
||||
import { CollaborationProvider } from '../CollaborationProvider';
|
||||
|
||||
const mockApplyUpdate = jest.fn();
|
||||
jest.mock('yjs', () => ({
|
||||
...jest.requireActual('yjs'),
|
||||
applyUpdate: (...args: any) => mockApplyUpdate(...args),
|
||||
}));
|
||||
|
||||
describe('CollaborationProvider', () => {
|
||||
let config: any;
|
||||
let provider: CollaborationProvider;
|
||||
let fakeWebsocketProvider: any;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeWebsocketProvider = {
|
||||
on: jest.fn(),
|
||||
open: jest.fn(),
|
||||
attach: jest.fn(),
|
||||
};
|
||||
config = {
|
||||
name: 'test',
|
||||
url: 'ws://localhost/ws/',
|
||||
canEdit: true,
|
||||
websocketProvider: fakeWebsocketProvider,
|
||||
};
|
||||
provider = new CollaborationProvider(config);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('constructor initializes properties and attaches event handlers', () => {
|
||||
expect(provider.canEdit).toBe(true);
|
||||
expect((provider as any).url).toBe('ws://localhost/ws/');
|
||||
expect(fakeWebsocketProvider.on).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('getStateFingerprint returns a consistent hash', () => {
|
||||
const fingerprint1 = provider.getStateFingerprint(provider.document);
|
||||
const fingerprint2 = provider.getStateFingerprint(provider.document);
|
||||
expect(typeof fingerprint1).toBe('string');
|
||||
expect(fingerprint1).toBe(fingerprint2);
|
||||
});
|
||||
|
||||
test('onPollOutgoingMessage does nothing when websocket is not failed', async () => {
|
||||
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
|
||||
body: JSON.stringify({ updated: false }),
|
||||
});
|
||||
|
||||
provider.isWebsocketFailed = false;
|
||||
const dummyMessage = {
|
||||
toUint8Array: () => new Uint8Array([1, 2, 3]),
|
||||
} as any;
|
||||
await provider.onPollOutgoingMessage({ message: dummyMessage });
|
||||
expect(fetchMock.called()).toBe(false);
|
||||
});
|
||||
|
||||
test('onPollOutgoingMessage calls pollOutgoingMessageRequest and pollSync when updated is false', async () => {
|
||||
provider.isWebsocketFailed = true;
|
||||
const dummyMessage = {
|
||||
toUint8Array: () => new Uint8Array([4, 5, 6]),
|
||||
} as any;
|
||||
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
|
||||
body: JSON.stringify({ updated: false }),
|
||||
});
|
||||
const pollSyncSpy = jest.spyOn(provider, 'pollSync').mockResolvedValue();
|
||||
await provider.onPollOutgoingMessage({ message: dummyMessage });
|
||||
expect(fetchMock.lastUrl()).toContain('http://localhost/ws/poll/message/');
|
||||
expect(pollSyncSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('onPollOutgoingMessage disables editing (canEdit becomes false) if API returns a 403 error', async () => {
|
||||
provider.isWebsocketFailed = true;
|
||||
const dummyMessage = {
|
||||
toUint8Array: () => new Uint8Array([7, 8, 9]),
|
||||
} as any;
|
||||
fetchMock.post(/http:\/\/localhost\/ws\/poll\/message\/.*/, {
|
||||
status: 403,
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// Stub the off method (inherited from event emitter) to observe its call.
|
||||
provider.off = jest.fn();
|
||||
await provider.onPollOutgoingMessage({ message: dummyMessage });
|
||||
expect(fetchMock.lastUrl()).toContain('http://localhost/ws/poll/message/');
|
||||
expect(provider.off).toHaveBeenCalled();
|
||||
expect(provider.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
test('pollSync does nothing if websocket is not failed', async () => {
|
||||
fetchMock.post(/http:\/\/localhost\/ws\/poll\/sync\/.*/, {
|
||||
body: JSON.stringify({ syncDoc64: '123456' }),
|
||||
});
|
||||
|
||||
provider.isWebsocketFailed = false;
|
||||
await provider.pollSync();
|
||||
expect(fetchMock.called()).toBe(false);
|
||||
});
|
||||
|
||||
test('pollSync calls postPollSyncRequest when unsync count threshold is reached', async () => {
|
||||
const update = Y.encodeStateAsUpdate(provider.document);
|
||||
const syncDoc64 = Buffer.from(update).toString('base64');
|
||||
|
||||
fetchMock.post(/http:\/\/localhost\/ws\/poll\/sync\/.*/, {
|
||||
body: JSON.stringify({ syncDoc64 }),
|
||||
});
|
||||
|
||||
provider.isWebsocketFailed = true;
|
||||
provider.seemsUnsyncCount = provider.seemsUnsyncMaxCount - 1;
|
||||
|
||||
await provider.pollSync();
|
||||
const uint8Array = Buffer.from(syncDoc64, 'base64');
|
||||
expect(mockApplyUpdate).toHaveBeenCalledWith(provider.document, uint8Array);
|
||||
});
|
||||
|
||||
describe('onStatus', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('sets websocket failed and schedules polling on Connecting', () => {
|
||||
const initPollingSpy = jest
|
||||
.spyOn(provider, 'initPolling')
|
||||
.mockImplementation(() => {});
|
||||
const superOnStatusSpy = jest.spyOn(
|
||||
Object.getPrototypeOf(provider),
|
||||
'onStatus',
|
||||
);
|
||||
|
||||
provider.onStatus({ status: WebSocketStatus.Connecting });
|
||||
|
||||
expect(provider.isWebsocketFailed).toBe(true);
|
||||
// Fast-forward timer to trigger the scheduled initPolling call.
|
||||
jest.runAllTimers();
|
||||
expect(initPollingSpy).toHaveBeenCalled();
|
||||
|
||||
expect(superOnStatusSpy).toHaveBeenCalledWith({
|
||||
status: WebSocketStatus.Connecting,
|
||||
});
|
||||
});
|
||||
|
||||
test('calls setPollDefaultValues on Connected', () => {
|
||||
const setPollDefaultValuesSpy = jest
|
||||
.spyOn(provider, 'setPollDefaultValues')
|
||||
.mockImplementation(() => {});
|
||||
const superOnStatusSpy = jest.spyOn(
|
||||
Object.getPrototypeOf(provider),
|
||||
'onStatus',
|
||||
);
|
||||
|
||||
provider.onStatus({ status: WebSocketStatus.Connected });
|
||||
|
||||
expect(setPollDefaultValuesSpy).toHaveBeenCalled();
|
||||
expect(superOnStatusSpy).toHaveBeenCalledWith({
|
||||
status: WebSocketStatus.Connected,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -4,10 +4,13 @@ import { create } from 'zustand';
|
||||
|
||||
import { Base64 } from '@/features/docs/doc-management';
|
||||
|
||||
import { CollaborationProvider } from '../libs/CollaborationProvider';
|
||||
|
||||
export interface UseCollaborationStore {
|
||||
createProvider: (
|
||||
providerUrl: string,
|
||||
storeId: string,
|
||||
canEdit: boolean,
|
||||
initialDoc?: Base64,
|
||||
) => HocuspocusProvider;
|
||||
destroyProvider: () => void;
|
||||
@@ -20,7 +23,7 @@ const defaultValues = {
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
...defaultValues,
|
||||
createProvider: (wsUrl, storeId, initialDoc) => {
|
||||
createProvider: (wsUrl, storeId, canEdit, initialDoc) => {
|
||||
const doc = new Y.Doc({
|
||||
guid: storeId,
|
||||
});
|
||||
@@ -29,10 +32,11 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
}
|
||||
|
||||
const provider = new HocuspocusProvider({
|
||||
const provider = new CollaborationProvider({
|
||||
url: wsUrl,
|
||||
name: storeId,
|
||||
document: doc,
|
||||
canEdit,
|
||||
});
|
||||
|
||||
set({
|
||||
|
||||
@@ -12,6 +12,9 @@ export const currentDocRole = (abilities: Doc['abilities']): Role => {
|
||||
: Role.READER;
|
||||
};
|
||||
|
||||
export const toBase64 = (str: Uint8Array) =>
|
||||
Buffer.from(str).toString('base64');
|
||||
|
||||
export const base64ToYDoc = (base64: string) => {
|
||||
const uint8Array = Buffer.from(base64, 'base64');
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
+18
-15
@@ -36,8 +36,9 @@ export const ModalSelectVersion = ({
|
||||
const { t } = useTranslation();
|
||||
const [selectedVersionId, setSelectedVersionId] =
|
||||
useState<Versions['version_id']>();
|
||||
|
||||
const canRestore = doc.abilities.partial_update;
|
||||
const restoreModal = useModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -127,21 +128,23 @@ export const ModalSelectVersion = ({
|
||||
selectedVersionId={selectedVersionId}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
$padding="xs"
|
||||
$css={css`
|
||||
border-top: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
`}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
disabled={!selectedVersionId}
|
||||
onClick={restoreModal.open}
|
||||
color="primary"
|
||||
{canRestore && (
|
||||
<Box
|
||||
$padding="xs"
|
||||
$css={css`
|
||||
border-top: 1px solid var(--c--theme--colors--greyscale-200);
|
||||
`}
|
||||
>
|
||||
{t('Restore')}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button
|
||||
fullWidth
|
||||
disabled={!selectedVersionId}
|
||||
onClick={restoreModal.open}
|
||||
color="primary"
|
||||
>
|
||||
{t('Restore')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { createGlobalStyle, css } from 'styled-components';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
|
||||
const DocsGridLoaderStyle = createGlobalStyle`
|
||||
body, main {
|
||||
@@ -27,15 +26,11 @@ export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
|
||||
data-testid="grid-loader"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$height="calc(100vh - 50px)"
|
||||
$height="100%"
|
||||
$width="100%"
|
||||
$maxWidth="960px"
|
||||
$background="rgba(255, 255, 255, 0.3)"
|
||||
$background="rgba(255, 255, 255, 0.5)"
|
||||
$zIndex={998}
|
||||
$position="fixed"
|
||||
$css={css`
|
||||
top: ${HEADER_HEIGHT}px;
|
||||
`}
|
||||
$position="absolute"
|
||||
>
|
||||
<Loader />
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { Doc, useTrans } from '@/features/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import PinnedDocumentIcon from '../assets/pinned-document.svg';
|
||||
@@ -35,6 +35,7 @@ export const SimpleDocItem = ({
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const spacings = spacingsTokens();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
return (
|
||||
<Box $direction="row" $gap={spacings.sm}>
|
||||
@@ -61,7 +62,7 @@ export const SimpleDocItem = ({
|
||||
$weight="500"
|
||||
$css={ItemTextCss}
|
||||
>
|
||||
{doc.title}
|
||||
{doc.title || untitledDocument}
|
||||
</Text>
|
||||
{(!isDesktop || showAccesses) && (
|
||||
<Box
|
||||
|
||||
@@ -50,7 +50,7 @@ export const Header = () => {
|
||||
$height="fit-content"
|
||||
$margin={{ top: 'auto' }}
|
||||
>
|
||||
<IconDocs aria-label={t('Docs Logo')} width={25} />
|
||||
<IconDocs aria-label={t('Docs Logo')} width={32} />
|
||||
<Title />
|
||||
</Box>
|
||||
</StyledLink>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
};
|
||||
|
||||
const createNewDoc = () => {
|
||||
createDoc({ title: t('Untitled document') });
|
||||
createDoc();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -63,7 +63,7 @@ const DocPage = ({ id }: DocProps) => {
|
||||
const { addTask } = useBroadcastStore();
|
||||
const queryClient = useQueryClient();
|
||||
const { replace } = useRouter();
|
||||
useCollaboration(doc?.id, doc?.content);
|
||||
useCollaboration(doc?.id, doc?.content, doc?.abilities.partial_update);
|
||||
|
||||
useEffect(() => {
|
||||
if (doc?.title) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// This file is used to declare the types for the @ag-media/react-pdf-table package
|
||||
// This library does not export its types in the "exports" field in the package.json.
|
||||
declare module '@ag-media/react-pdf-table' {
|
||||
export const TD: React.FC<any>;
|
||||
export const TH: React.FC<any>;
|
||||
export const TR: React.FC<any>;
|
||||
export const Table: React.FC<any>;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { MessageType } from '@hocuspocus/server';
|
||||
import { Response } from 'express';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { PollSync, PollSyncRequest } from '../src/libs/PollSync';
|
||||
|
||||
const { base64ToYDoc } = require('@/utils');
|
||||
const { logger } = require('@/utils');
|
||||
|
||||
const mockEncodeStateAsUpdate = jest.fn();
|
||||
jest.mock('yjs', () => ({
|
||||
...jest.requireActual('yjs'),
|
||||
encodeStateAsUpdate: () => mockEncodeStateAsUpdate(),
|
||||
}));
|
||||
|
||||
jest.mock('@/utils', () => ({
|
||||
base64ToYDoc: jest.fn((_b64: string) => new Y.Doc()),
|
||||
toBase64: jest.fn((data: Uint8Array) => Buffer.from(data).toString('base64')),
|
||||
logger: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@hocuspocus/server', () => {
|
||||
const originalModule = jest.requireActual('@hocuspocus/server');
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
IncomingMessage: jest.fn().mockImplementation((buf: Buffer) => ({
|
||||
buffer: buf,
|
||||
readVarString: jest.fn(() => 'testRoom'),
|
||||
readVarUint: jest.fn(() => MessageType.Sync),
|
||||
writeVarUint: jest.fn(),
|
||||
decoder: {},
|
||||
encoder: {},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('PollSync', () => {
|
||||
let req: PollSyncRequest<any>;
|
||||
let res: Response;
|
||||
let dummyDoc: any;
|
||||
let pollSync: PollSync<any>;
|
||||
let mockHocuspocusServer: any;
|
||||
|
||||
beforeEach(() => {
|
||||
req = Object.assign(new EventEmitter(), {
|
||||
on: jest.fn((event, cb) => {
|
||||
// For 'close' event, store the callback for manual trigger in tests.
|
||||
if (event === 'close') {
|
||||
req.destroy = cb;
|
||||
}
|
||||
}),
|
||||
}) as unknown as PollSyncRequest<any>;
|
||||
|
||||
res = {
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as unknown as Response;
|
||||
|
||||
// Create a dummy document with required methods/properties
|
||||
dummyDoc = {
|
||||
name: 'testRoom',
|
||||
merge: jest.fn((_other: Y.Doc) => {
|
||||
// Simulate merging by returning a new Y.Doc instance
|
||||
return new Y.Doc();
|
||||
}),
|
||||
getConnections: jest.fn(() => [{ handleMessage: jest.fn() }]),
|
||||
awareness: {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
},
|
||||
addDirectConnection: jest.fn(),
|
||||
removeDirectConnection: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
};
|
||||
|
||||
pollSync = new PollSync(req, 'testRoom', true);
|
||||
// Pre-set the document for non-init tests
|
||||
(pollSync as any)._hpDocument = dummyDoc;
|
||||
|
||||
// Create a dummy Hocuspocus server
|
||||
mockHocuspocusServer = {
|
||||
loadingDocuments: { get: jest.fn() },
|
||||
documents: new Map<string, any>(),
|
||||
createDocument: jest.fn(() => ({
|
||||
name: 'newDoc',
|
||||
merge: (doc: Y.Doc) => doc,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initHocuspocusDocument', () => {
|
||||
it('should return document from loadingDocuments when available', async () => {
|
||||
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(dummyDoc);
|
||||
pollSync = new PollSync(req, 'testRoom', true);
|
||||
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
|
||||
expect(doc).toBe(dummyDoc);
|
||||
expect(mockHocuspocusServer.loadingDocuments.get).toHaveBeenCalledWith(
|
||||
'testRoom',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return document from documents when available', async () => {
|
||||
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
|
||||
mockHocuspocusServer.documents.set('testRoom', dummyDoc);
|
||||
pollSync = new PollSync(req, 'testRoom', false);
|
||||
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
|
||||
expect(doc).toBe(dummyDoc);
|
||||
});
|
||||
|
||||
it('should create a new document when none exists and canEdit is true', async () => {
|
||||
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
|
||||
pollSync = new PollSync(req, 'testRoom', true);
|
||||
const newDoc = { name: 'newDoc', merge: (doc: any) => doc };
|
||||
mockHocuspocusServer.createDocument.mockResolvedValue(newDoc);
|
||||
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
|
||||
expect(doc).toBe(newDoc);
|
||||
expect(mockHocuspocusServer.createDocument).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
it('should encode state without merging when canEdit is false', () => {
|
||||
// When user cannot edit, merge should not be called.
|
||||
pollSync = new PollSync(req, 'testRoom', false);
|
||||
(pollSync as any)._hpDocument = dummyDoc;
|
||||
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([1, 2, 3]));
|
||||
|
||||
const result = pollSync.sync('dummyLocalDoc64');
|
||||
expect(dummyDoc.merge).not.toHaveBeenCalled();
|
||||
expect(result).toBe(
|
||||
Buffer.from(Uint8Array.from([1, 2, 3])).toString('base64'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge local doc when canEdit is true', () => {
|
||||
pollSync = new PollSync(req, 'testRoom', true);
|
||||
(pollSync as any)._hpDocument = dummyDoc;
|
||||
const localDoc = new Y.Doc();
|
||||
// Mock base64ToYDoc to return our localDoc
|
||||
base64ToYDoc.mockReturnValue(localDoc);
|
||||
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([4, 5, 6]));
|
||||
pollSync.sync('localDoc64');
|
||||
expect(dummyDoc.merge).toHaveBeenCalledWith(localDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendClientsMessages', () => {
|
||||
it('should log an error if room names do not match', () => {
|
||||
// Set doc name different from what IncomingMessage returns ('testRoom')
|
||||
dummyDoc.name = 'differentRoom';
|
||||
(pollSync as any)._hpDocument = dummyDoc;
|
||||
const fakeMessage = Buffer.from('fakeMessage').toString('base64');
|
||||
pollSync.sendClientsMessages(fakeMessage);
|
||||
expect(logger).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullClientsMessages', () => {
|
||||
it('should register event listeners and cleanup on request close', () => {
|
||||
(pollSync as any)._hpDocument = dummyDoc;
|
||||
pollSync.pullClientsMessages(res);
|
||||
expect(dummyDoc.addDirectConnection).toHaveBeenCalled();
|
||||
expect(dummyDoc.on).toHaveBeenCalled();
|
||||
expect(dummyDoc.awareness.on).toHaveBeenCalled();
|
||||
|
||||
// Simulate a 'close' event on the request
|
||||
if (req.destroy) {
|
||||
req.destroy();
|
||||
}
|
||||
expect(dummyDoc.removeDirectConnection).toHaveBeenCalled();
|
||||
// Verify that document listeners are removed (off was called)
|
||||
expect(dummyDoc.off).toHaveBeenCalled();
|
||||
expect(dummyDoc.awareness.off).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
import {
|
||||
collaborationPollPostMessageHandler,
|
||||
collaborationPollSSEMessageHandler,
|
||||
collaborationPollSyncDocHandler,
|
||||
} from '../src/handlers/collaborationPollHandler';
|
||||
|
||||
const mockInitHocuspocusDocument = jest.fn();
|
||||
const mockSendClientsMessages = jest.fn();
|
||||
const mockSync = jest.fn();
|
||||
const mockPullClientsMessages = jest.fn();
|
||||
|
||||
jest.mock('@/libs/PollSync', () => {
|
||||
return {
|
||||
PollSync: jest.fn().mockImplementation(() => ({
|
||||
initHocuspocusDocument: mockInitHocuspocusDocument,
|
||||
sendClientsMessages: mockSendClientsMessages,
|
||||
sync: mockSync,
|
||||
pullClientsMessages: mockPullClientsMessages,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@/servers/hocusPocusServer', () => ({
|
||||
hocusPocusServer: {},
|
||||
}));
|
||||
|
||||
// Helper function to create a mock response
|
||||
function createResponse<T>() {
|
||||
const res: Partial<Response<T>> = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.setHeader = jest.fn();
|
||||
res.write = jest.fn();
|
||||
res.headersSent = false;
|
||||
return res as Response<T>;
|
||||
}
|
||||
|
||||
describe('collaborationPollPostMessageHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
|
||||
});
|
||||
|
||||
it('should return 403 if user is not allowed to edit', async () => {
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'False' },
|
||||
body: { message64: 'testMessage' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollPostMessageHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('should return 400 if room is not provided', async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { message64: 'testMessage' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollPostMessageHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
|
||||
});
|
||||
|
||||
it('should return 404 if document is not found', async () => {
|
||||
mockInitHocuspocusDocument.mockResolvedValue(null);
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { message64: 'testMessage' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
res.headersSent = false;
|
||||
await collaborationPollPostMessageHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
|
||||
});
|
||||
|
||||
it('should process message and return updated true when successful', async () => {
|
||||
// Reset headerSent to false to simulate a proper response
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { message64: 'testMessage' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
res.headersSent = false;
|
||||
await collaborationPollPostMessageHandler(req, res);
|
||||
expect(mockSendClientsMessages).toHaveBeenCalledWith('testMessage');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ updated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('collaborationPollSyncDocHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
|
||||
mockSync.mockReturnValue('syncDocEncoded');
|
||||
});
|
||||
|
||||
it('should return 400 if room is not provided', async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { localDoc64: 'localDocEncoded' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollSyncDocHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
|
||||
});
|
||||
|
||||
it('should return 404 if document is not found', async () => {
|
||||
mockInitHocuspocusDocument.mockResolvedValue(null);
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { localDoc64: 'localDocEncoded' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollSyncDocHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
|
||||
});
|
||||
|
||||
it('should sync document and return syncDoc64 when successful', async () => {
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
body: { localDoc64: 'localDocEncoded' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
res.headersSent = false;
|
||||
await collaborationPollSyncDocHandler(req, res);
|
||||
expect(mockSync).toHaveBeenCalledWith('localDocEncoded');
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ syncDoc64: 'syncDocEncoded' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('collaborationPollSSEMessageHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
|
||||
});
|
||||
|
||||
it('should return 400 if room is not provided', async () => {
|
||||
const req = {
|
||||
query: {},
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollSSEMessageHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
|
||||
});
|
||||
|
||||
it('should return 404 if document is not found', async () => {
|
||||
mockInitHocuspocusDocument.mockResolvedValue(null);
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
await collaborationPollSSEMessageHandler(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
|
||||
});
|
||||
|
||||
it('should set SSE headers and send connected message when successful', async () => {
|
||||
const req = {
|
||||
query: { room: 'test-room' },
|
||||
headers: { 'x-can-edit': 'True' },
|
||||
} as any;
|
||||
const res = createResponse();
|
||||
res.headersSent = false;
|
||||
await collaborationPollSSEMessageHandler(req, res);
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/event-stream',
|
||||
);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||
expect(res.write).toHaveBeenCalledWith(': connected\n\n');
|
||||
expect(mockPullClientsMessages).toHaveBeenCalledWith(res);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ var config = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/../src/$1',
|
||||
'^@blocknote/server-util$': '<rootDir>/../__mocks__/mock.js',
|
||||
'^y-protocols/awareness.js$': '<rootDir>/../__mocks__/mock.js',
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"cors": "2.8.5",
|
||||
"express": "4.21.2",
|
||||
"express-ws": "5.0.2",
|
||||
"uuid": "11.0.5",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "13.6.23"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,8 @@ export const COLLABORATION_SERVER_ORIGIN =
|
||||
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000';
|
||||
export const COLLABORATION_SERVER_SECRET =
|
||||
process.env.COLLABORATION_SERVER_SECRET || 'secret-api-key';
|
||||
export const Y_PROVIDER_API_KEY =
|
||||
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
|
||||
export const PORT = Number(process.env.PORT || 4444);
|
||||
export const SENTRY_DSN = process.env.SENTRY_DSN || '';
|
||||
export const SENTRY_ENV = process.env.SENTRY_ENV || 'Development';
|
||||
export const Y_PROVIDER_API_KEY =
|
||||
process.env.Y_PROVIDER_API_KEY || 'yprovider-api-key';
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
import { PollSync, PollSyncRequest } from '@/libs/PollSync';
|
||||
import { hocusPocusServer } from '@/servers/hocusPocusServer';
|
||||
|
||||
interface CollaborationPollPostMessagePayload {
|
||||
message64: string;
|
||||
}
|
||||
interface CollaborationPollPostMessageResponse {
|
||||
updated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const collaborationPollPostMessageHandler = async (
|
||||
req: PollSyncRequest<CollaborationPollPostMessagePayload>,
|
||||
res: Response<CollaborationPollPostMessageResponse>,
|
||||
) => {
|
||||
const room = req.query.room;
|
||||
const canEdit = req.headers['x-can-edit'] === 'True';
|
||||
|
||||
// Only editors can send messages
|
||||
if (!canEdit) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
res.status(400).json({ error: 'Room name not provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pollSynch = new PollSync<CollaborationPollPostMessagePayload>(
|
||||
req,
|
||||
room,
|
||||
canEdit,
|
||||
);
|
||||
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
|
||||
|
||||
if (!res.headersSent && !hpDoc) {
|
||||
res.status(404).json({ error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
pollSynch.sendClientsMessages(req.body.message64);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(200).json({ updated: true });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Polling way of handling collaboration
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
interface CollaborationPollSyncDocResponse {
|
||||
syncDoc64?: string;
|
||||
error?: string;
|
||||
}
|
||||
interface CollaborationPollSyncDocBody {
|
||||
localDoc64: string;
|
||||
}
|
||||
|
||||
export const collaborationPollSyncDocHandler = async (
|
||||
req: PollSyncRequest<CollaborationPollSyncDocBody>,
|
||||
res: Response<CollaborationPollSyncDocResponse>,
|
||||
) => {
|
||||
const room = req.query.room;
|
||||
const canEdit = req.headers['x-can-edit'] === 'True';
|
||||
|
||||
if (!room) {
|
||||
res.status(400).json({ error: 'Room name not provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pollSynch = new PollSync<CollaborationPollSyncDocBody>(
|
||||
req,
|
||||
room,
|
||||
canEdit,
|
||||
);
|
||||
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
|
||||
|
||||
if (!hpDoc) {
|
||||
res.status(404).json({ error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const syncDoc64 = pollSynch.sync(req.body.localDoc64);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(200).json({ syncDoc64 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SSE message handling
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
interface CollaborationPollSSEMessageResponse {
|
||||
updatedDoc64?: string;
|
||||
stateFingerprint?: string;
|
||||
awareness64?: string;
|
||||
error?: string;
|
||||
}
|
||||
export const collaborationPollSSEMessageHandler = async (
|
||||
req: PollSyncRequest<void>,
|
||||
res: Response<CollaborationPollSSEMessageResponse>,
|
||||
) => {
|
||||
const room = req.query.room;
|
||||
const canEdit = req.headers['x-can-edit'] === 'True';
|
||||
|
||||
if (!room) {
|
||||
res.status(400).json({ error: 'Room name not provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pollSynch = new PollSync<void>(req, room, canEdit);
|
||||
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
|
||||
|
||||
if (!hpDoc) {
|
||||
res.status(404).json({ error: 'Document not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.write(': connected\n\n');
|
||||
|
||||
pollSynch.pullClientsMessages(res);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './collaborationResetConnectionsHandler';
|
||||
export * from './collaborationWSHandler';
|
||||
export * from './collaborationPollHandler';
|
||||
export * from './convertMarkdownHandler';
|
||||
export * from './collaborationWSHandler';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const promiseDone = () => {
|
||||
let done: (value: void | PromiseLike<void>) => void = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
export const promiseDone = <T = void>() => {
|
||||
let done: (value: T | PromiseLike<T>) => void = () => {};
|
||||
const promise = new Promise<T>((resolve) => {
|
||||
done = resolve;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import crypto from 'crypto';
|
||||
|
||||
import {
|
||||
AwarenessUpdate,
|
||||
Document,
|
||||
Hocuspocus,
|
||||
IncomingMessage,
|
||||
MessageType,
|
||||
OutgoingMessage,
|
||||
} from '@hocuspocus/server';
|
||||
import { Request, Response } from 'express';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { applyAwarenessUpdate } from 'y-protocols/awareness.js';
|
||||
import { readSyncMessage } from 'y-protocols/sync';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { base64ToYDoc, logger, toBase64 } from '@/utils';
|
||||
|
||||
export type PollSyncRequestQuery = {
|
||||
room?: string;
|
||||
};
|
||||
|
||||
export type PollSyncRequest<T> = Request<
|
||||
object,
|
||||
object,
|
||||
T,
|
||||
PollSyncRequestQuery
|
||||
>;
|
||||
|
||||
export class PollSync<T> {
|
||||
public readonly canEdit: boolean;
|
||||
public readonly req: PollSyncRequest<T>;
|
||||
public readonly room: string;
|
||||
private _hpDocument?: Document;
|
||||
|
||||
constructor(req: PollSyncRequest<T>, room: string, canEdit: boolean) {
|
||||
this.room = room;
|
||||
this.canEdit = canEdit;
|
||||
this.req = req;
|
||||
}
|
||||
|
||||
public get hpDocument() {
|
||||
return this._hpDocument;
|
||||
}
|
||||
|
||||
public async initHocuspocusDocument(hocusPocusServer: Hocuspocus) {
|
||||
const { req, room, canEdit } = this;
|
||||
this._hpDocument = await hocusPocusServer.loadingDocuments.get(room);
|
||||
|
||||
if (this._hpDocument) {
|
||||
return this._hpDocument;
|
||||
}
|
||||
|
||||
this._hpDocument = hocusPocusServer.documents.get(room);
|
||||
|
||||
if (this._hpDocument || (!this._hpDocument && !canEdit)) {
|
||||
return this._hpDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* createDocument is used to create a new document if it does not exist.
|
||||
* If the document exists, it will return the existing document.
|
||||
*/
|
||||
this._hpDocument = await hocusPocusServer.createDocument(
|
||||
room,
|
||||
req,
|
||||
uuid(),
|
||||
{
|
||||
readOnly: false,
|
||||
requiresAuthentication: false,
|
||||
isAuthenticated: false,
|
||||
},
|
||||
);
|
||||
|
||||
return this._hpDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the document with the latest changes
|
||||
*
|
||||
* @param localDoc64
|
||||
* @returns
|
||||
*/
|
||||
public sync(localDoc64: string): string | undefined {
|
||||
const hpDoc = this.getHpDocument();
|
||||
let syncYDoc = hpDoc;
|
||||
|
||||
// Merge the coming document with the latest changes (only if the user can edit)
|
||||
if (this.canEdit) {
|
||||
const localDoc = base64ToYDoc(localDoc64);
|
||||
syncYDoc = hpDoc.merge(localDoc);
|
||||
}
|
||||
|
||||
return toBase64(Y.encodeStateAsUpdate(syncYDoc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash SHA-256 of the state vector of the document.
|
||||
* Usefull to compare the state of the document.
|
||||
* @param doc
|
||||
* @returns
|
||||
*/
|
||||
protected getStateFingerprint(doc: Y.Doc): string {
|
||||
const stateVector = Y.encodeStateVector(doc);
|
||||
return crypto.createHash('sha256').update(stateVector).digest('base64'); // or 'hex'
|
||||
}
|
||||
|
||||
/**
|
||||
* Send messages to other clients
|
||||
*/
|
||||
public sendClientsMessages(message64: string) {
|
||||
const hpDoc = this.getHpDocument();
|
||||
const messageBuffer = Buffer.from(message64, 'base64');
|
||||
const message = new IncomingMessage(messageBuffer);
|
||||
const room = message.readVarString();
|
||||
|
||||
if (hpDoc.name !== room) {
|
||||
logger('Send messages problem, room different', room, hpDoc.name);
|
||||
return;
|
||||
}
|
||||
|
||||
// We write the sync to the current doc - it will propagate to others by itself
|
||||
const type = message.readVarUint() as MessageType;
|
||||
if (type === MessageType.Sync) {
|
||||
message.writeVarUint(MessageType.Sync);
|
||||
readSyncMessage(message.decoder, message.encoder, hpDoc, null);
|
||||
} else if (type === MessageType.Awareness) {
|
||||
const awarenessUpdate = message.readVarUint8Array();
|
||||
applyAwarenessUpdate(
|
||||
hpDoc.awareness,
|
||||
awarenessUpdate,
|
||||
hpDoc.awareness.clientID,
|
||||
);
|
||||
} else {
|
||||
hpDoc.getConnections().forEach((connection) => {
|
||||
connection.handleMessage(messageBuffer);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull messages from other clients
|
||||
*
|
||||
* We listen 2 kind of messages:
|
||||
* - Document updates (change in the document)
|
||||
* - Awareness messages (cursor, selection, etc.)
|
||||
*
|
||||
* @param res
|
||||
*/
|
||||
public pullClientsMessages(res: Response) {
|
||||
const hpDoc = this.getHpDocument();
|
||||
hpDoc.addDirectConnection();
|
||||
|
||||
const updateMessagesFn = (
|
||||
update: Uint8Array,
|
||||
_origin: string,
|
||||
updatedDoc: Y.Doc,
|
||||
_transaction: Y.Transaction,
|
||||
) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
time: new Date(),
|
||||
updatedDoc64: toBase64(update),
|
||||
stateFingerprint: this.getStateFingerprint(updatedDoc),
|
||||
})}\n\n`,
|
||||
);
|
||||
};
|
||||
|
||||
const destroyFn = (updatedDoc: Y.Doc) => {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
time: new Date(),
|
||||
updatedDoc64: undefined,
|
||||
stateFingerprint: this.getStateFingerprint(updatedDoc),
|
||||
})}\n\n`,
|
||||
);
|
||||
|
||||
hpDoc.off('destroy', destroyFn);
|
||||
hpDoc.off('update', updateMessagesFn);
|
||||
|
||||
// Close the connection
|
||||
res.end();
|
||||
};
|
||||
|
||||
const updateAwarenessFn = ({
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
}: AwarenessUpdate) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
const awarenessMessage = new OutgoingMessage(
|
||||
this.room,
|
||||
).createAwarenessUpdateMessage(hpDoc.awareness, changedClients);
|
||||
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
time: new Date(),
|
||||
awareness64: toBase64(awarenessMessage.toUint8Array()),
|
||||
stateFingerprint: this.getStateFingerprint(hpDoc),
|
||||
})}\n\n`,
|
||||
);
|
||||
};
|
||||
|
||||
hpDoc.awareness.off('update', updateAwarenessFn);
|
||||
hpDoc.awareness.on('update', updateAwarenessFn);
|
||||
hpDoc.off('update', updateMessagesFn);
|
||||
hpDoc.off('destroy', destroyFn);
|
||||
hpDoc.on('update', updateMessagesFn);
|
||||
hpDoc.on('destroy', destroyFn);
|
||||
|
||||
this.req.on('close', () => {
|
||||
hpDoc.off('update', updateMessagesFn);
|
||||
hpDoc.off('destroy', destroyFn);
|
||||
hpDoc.awareness.off('update', updateAwarenessFn);
|
||||
hpDoc.removeDirectConnection();
|
||||
});
|
||||
}
|
||||
|
||||
protected getHpDocument() {
|
||||
if (!this.hpDocument) {
|
||||
throw new Error('HocusPocus document not initialized');
|
||||
}
|
||||
|
||||
return this.hpDocument;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export const routes = {
|
||||
COLLABORATION_WS: '/collaboration/ws/',
|
||||
COLLABORATION_POLL_MESSAGE: '/collaboration/ws/poll/message/',
|
||||
COLLABORATION_POLL_SYNC: '/collaboration/ws/poll/sync/',
|
||||
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
|
||||
CONVERT_MARKDOWN: '/api/convert-markdown/',
|
||||
};
|
||||
|
||||
@@ -6,6 +6,9 @@ import expressWebsockets from 'express-ws';
|
||||
|
||||
import { PORT } from '../env';
|
||||
import {
|
||||
collaborationPollPostMessageHandler,
|
||||
collaborationPollSSEMessageHandler,
|
||||
collaborationPollSyncDocHandler,
|
||||
collaborationResetConnectionsHandler,
|
||||
collaborationWSHandler,
|
||||
convertMarkdownHandler,
|
||||
@@ -27,9 +30,24 @@ export const initServer = () => {
|
||||
app.use(corsMiddleware);
|
||||
|
||||
/**
|
||||
* Route to handle WebSocket connections
|
||||
* Routes to handle collaboration connections
|
||||
*/
|
||||
app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler);
|
||||
app.get(
|
||||
routes.COLLABORATION_POLL_MESSAGE,
|
||||
httpSecurity,
|
||||
collaborationPollSSEMessageHandler,
|
||||
);
|
||||
app.post(
|
||||
routes.COLLABORATION_POLL_MESSAGE,
|
||||
httpSecurity,
|
||||
collaborationPollPostMessageHandler,
|
||||
);
|
||||
app.post(
|
||||
routes.COLLABORATION_POLL_SYNC,
|
||||
httpSecurity,
|
||||
collaborationPollSyncDocHandler,
|
||||
);
|
||||
|
||||
/**
|
||||
* Route to reset connections in a room:
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { nodeProfilingIntegration } from '@sentry/profiling-node';
|
||||
|
||||
import { SENTRY_DSN } from '../env';
|
||||
import { SENTRY_DSN, SENTRY_ENV } from '../env';
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
integrations: [nodeProfilingIntegration()],
|
||||
tracesSampleRate: 0.1,
|
||||
profilesSampleRate: 1.0,
|
||||
environment: SENTRY_ENV,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { COLLABORATION_LOGGING } from './env';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -8,6 +10,13 @@ export function logger(...args: any[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export const toBase64 = function (str: Uint8Array) {
|
||||
return Buffer.from(str).toString('base64');
|
||||
export const toBase64 = function (uInt8Array: Uint8Array) {
|
||||
return Buffer.from(uInt8Array).toString('base64');
|
||||
};
|
||||
|
||||
export const base64ToYDoc = (base64: string) => {
|
||||
const uint8Array = Buffer.from(base64, 'base64');
|
||||
const ydoc = new Y.Doc();
|
||||
Y.applyUpdate(ydoc, uint8Array);
|
||||
return ydoc;
|
||||
};
|
||||
|
||||
+13
-1
@@ -7,6 +7,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.1.tgz#2447a230bfe072c1659e6815129c03cf170710e3"
|
||||
integrity sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==
|
||||
|
||||
"@ag-media/react-pdf-table@2.0.1":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz#609e51992faed54bcf379a376442997c6bac53cc"
|
||||
integrity sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==
|
||||
|
||||
"@ampproject/remapping@^2.2.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4"
|
||||
@@ -5276,6 +5281,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ws@8.5.13":
|
||||
version "8.5.13"
|
||||
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20"
|
||||
integrity sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/yargs-parser@*":
|
||||
version "21.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"
|
||||
@@ -13929,7 +13941,7 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||
|
||||
uuid@^11.0.3:
|
||||
uuid@11.0.5, uuid@^11.0.3:
|
||||
version "11.0.5"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62"
|
||||
integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 2.2.0-beta.1
|
||||
version: 2.2.0-beta.2
|
||||
appVersion: latest
|
||||
|
||||
@@ -82,7 +82,11 @@ ingressCollaborationWS:
|
||||
## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/proxy-send-timeout
|
||||
## @param ingressCollaborationWS.annotations.nginx.ingress.kubernetes.io/upstream-hash-by
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-cache-key: "$http_authorization-$arg_room"
|
||||
nginx.ingress.kubernetes.io/auth-cache-duration: 200 30s
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Can-Edit, X-User-Id"
|
||||
nginx.ingress.kubernetes.io/auth-snippet: |
|
||||
proxy_set_header Accept "application/json";
|
||||
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/collaboration-auth/
|
||||
nginx.ingress.kubernetes.io/enable-websocket: "true"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "86400"
|
||||
|
||||
Reference in New Issue
Block a user