♻️(collaboration) switch collaboration server from hocuspocus to yhub

Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
Kevin Jahns
2026-08-03 16:13:52 +02:00
parent eed828d8c7
commit 3bc372c756
60 changed files with 1217 additions and 2521 deletions
+3
View File
@@ -47,6 +47,9 @@ env.d/terraform
compose.override.yml
docker/auth/*.local
# yhub server local install
src/yhub-server/node_modules/
# npm
node_modules
+8
View File
@@ -18,6 +18,14 @@ and this project adheres to
### Changed
- ♿️(frontend) use semantic `<dl>` structure in document info card #2379
- ♻️(collaboration) migrate the collaboration server from hocuspocus to yhub:
the dev stack gains dedicated valkey and postgres services for yhub, and
the kick (reset-connections) and get-connections APIs have no yhub
equivalent yet — they are deferred with TODO(yhub) stubs
- 💥(y-provider) the published `lasuite/impress-y-provider` image becomes
converter-only and no longer serves `/collaboration/ws/`; deployments using
the existing helm values lose collaboration until the helm chart routes
collaboration to yhub (follow-up)
## [v5.4.1] - 2026-07-09
+10 -5
View File
@@ -190,6 +190,7 @@ bootstrap-e2e: \
build: cache ?=
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yhub cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
@@ -199,9 +200,14 @@ build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev $(cache)
.PHONY: build-backend
build-yhub: cache ?=
build-yhub: ## build the yhub collaboration server container
@$(COMPOSE) build yhub $(cache)
.PHONY: build-yhub
build-yjs-provider: cache ?=
build-yjs-provider: ## build the y-provider container
@$(COMPOSE) build y-provider-development $(cache)
@$(COMPOSE) build y-provider-development-converter $(cache)
.PHONY: build-yjs-provider
build-frontend: cache ?=
@@ -212,8 +218,9 @@ build-frontend: ## build the frontend container
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yhub cache=$(cache)
@$(COMPOSE_E2E) build frontend $(cache)
@$(COMPOSE_E2E) build y-provider $(cache)
@$(COMPOSE_E2E) build y-provider-converter $(cache)
.PHONY: build-e2e
nginx-frontend: ## build the nginx-frontend container
@@ -232,8 +239,8 @@ run-backend: ## Start only the backend application and all needed services
@$(MAKE) create-docker-network
@$(COMPOSE) up --force-recreate -d docspec
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d y-provider-development-converter
@$(COMPOSE) up --force-recreate -d yhub
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
@@ -246,9 +253,7 @@ run:
run-e2e: ## start the e2e server
run-e2e:
@$(MAKE) run-backend
@$(COMPOSE_E2E) stop y-provider-development
@$(COMPOSE_E2E) up --force-recreate -d frontend
@$(COMPOSE_E2E) up --force-recreate -d y-provider
@$(COMPOSE_E2E) up --force-recreate -d y-provider-converter
.PHONY: run-e2e
+1 -14
View File
@@ -13,7 +13,7 @@ services:
ports:
- "3000:3000"
y-provider:
y-provider-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
@@ -24,16 +24,3 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
y-provider-converter:
user: ${DOCKER_USER:-1000}
image: impress:y-provider-production
restart: unless-stopped
env_file:
- env.d/development/common
- env.d/development/common.local
depends_on:
y-provider:
condition: service_started
+55 -12
View File
@@ -181,7 +181,7 @@ services:
volumes:
- ".:/app"
y-provider-development:
y-provider-development-converter:
user: ${DOCKER_USER:-1000}
build:
context: .
@@ -192,27 +192,66 @@ services:
env_file:
- env.d/development/common
- env.d/development/common.local
ports:
- "4444:4444"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
y-provider-development-converter:
yhub-valkey:
image: valkey/valkey:alpine
# volatile-lru per yhub DEPLOYMENT.md; AOF because valkey is the authoritative store
# for updates the worker hasn't persisted yet (up to taskDebounce+minMessageLifetime)
command: ["valkey-server", "--maxmemory-policy", "volatile-lru",
"--appendonly", "yes", "--appendfsync", "everysec"]
volumes:
- yhub-valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 1s
timeout: 2s
retries: 60
yhub-postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: yhub
POSTGRES_PASSWORD: yhub
POSTGRES_DB: yhub
volumes:
- yhub-pgdata:/var/lib/postgresql/data
# NOTE: initdb.d only runs on a FRESH volume; schema changes need `podman volume rm`
- ./docker/files/yhub/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U yhub"]
interval: 1s
timeout: 2s
retries: 60
# no published port (Django's postgres already publishes)
yhub:
user: ${DOCKER_USER:-1000}
image: impress:y-provider-development
restart: unless-stopped
build:
context: ./src/yhub-server
dockerfile: Dockerfile
target: yhub
image: impress:yhub
environment:
HOME: /tmp # same reason as node-based services above (unmapped uid)
PORT: 3002
REDIS: redis://yhub-valkey:6379
POSTGRES: postgres://yhub:yhub@yhub-postgres:5432/yhub
REDIS_PREFIX: yhub
env_file:
- env.d/development/common
- env.d/development/common.local
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/y-provider/node_modules
restart: unless-stopped
ports:
- "3002:3002"
depends_on:
y-provider-development:
condition: service_started
yhub-valkey:
condition: service_healthy
yhub-postgres:
condition: service_healthy
kc_postgresql:
image: postgres:14.3
@@ -268,3 +307,7 @@ networks:
name: lasuite-network
driver: bridge
external: true
volumes:
yhub-pgdata: {}
yhub-valkey-data: {}
+14
View File
@@ -0,0 +1,14 @@
-- Column-for-column from yhub bin/init-db.js (unquoted identifiers so
-- case-folding matches yhub's persistence.js queries).
CREATE TABLE IF NOT EXISTS yhub_ydoc_v1 (
org text,
docid text,
branch text,
t text,
created INT8,
gcDoc bytea,
nongcDoc bytea,
contentmap bytea,
contentids bytea,
PRIMARY KEY (org,docid,branch,t)
);
+1 -1
View File
@@ -89,7 +89,7 @@ Production deployments differ significantly from development environments. The t
| --------- | --------------------- |
| 3000 | Next.js |
| 8071 | Django |
| 4444 | Y-Provider |
| 3002 | yhub (collaboration WS) |
| 8080 | Keycloak |
| 8083 | Nginx proxy |
| 9000/9001 | MinIO |
+2 -2
View File
@@ -72,12 +72,12 @@ OIDC_RS_ALLOWED_AUDIENCES=""
USER_RECONCILIATION_FORM_URL=http://localhost:3000
# Collaboration
COLLABORATION_API_URL=http://y-provider-development:4444/collaboration/api/
# TODO(yhub): no management API yet
COLLABORATION_BACKEND_BASE_URL=http://app-dev:8000
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
COLLABORATION_SERVER_SECRET=my-secret
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY=true
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
COLLABORATION_WS_INACTIVITY_TIMEOUT=15 # Seconds
DJANGO_SERVER_TO_SERVER_API_TOKENS=server-api-token
-1
View File
@@ -1,6 +1,5 @@
# For the CI job test-e2e
BURST_THROTTLE_RATES="1000/minute"
COLLABORATION_API_URL=http://y-provider:4444/collaboration/api/
SUSTAINED_THROTTLE_RATES="1000/minute"
Y_PROVIDER_API_BASE_URL=http://y-provider-converter:4444/api/
+1
View File
@@ -6,4 +6,5 @@ FRONTEND_HOST=frontend
YPROVIDER_HOST=y-provider
BUCKET_NAME=docs-media-storage
REALM_NAME=docs
# TODO(yhub): route is /ws/docs once prod ingress is migrated
#COLLABORATION_WS_URL=wss://${DOCS_HOST}/collaboration/ws/
@@ -1,5 +1,13 @@
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
# TODO(yhub): this sandbox reset no longer erases the document content. It purges
# the S3 versions, but yhub durably retains the Yjs document in its own Postgres
# and re-serves it on the next websocket connect (CRDT merge with the empty
# seed resurrects the purged content). yhub has no delete API; until it grows
# one, the interim remediation is to run, against yhub's stores:
# DELETE FROM yhub_ydoc_v1 WHERE org='docs' AND docid='<document_id>';
# and drop the `yhub:room:docs:<document_id>:*` redis keys.
import logging
from django.conf import settings
@@ -2,102 +2,39 @@
from logging import getLogger
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import requests
from core import models
logger = getLogger(__name__)
class CollaborationService:
"""Service class for Collaboration related operations."""
def __init__(self):
"""Ensure that the collaboration configuration is set properly."""
if settings.COLLABORATION_API_URL is None:
raise ImproperlyConfigured("Collaboration configuration not set")
def reset_connections(self, document_id, user_id=None):
"""
Reset the connections of a document and all its descendants in the
collaboration server.
Resetting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
TODO(yhub): yhub exposes no kick API, so this is a no-op. The regression
is stronger than losing the hocuspocus disconnect: a revoked user keeps
their already-authorized websocket until it closes on its own, and the
edits they push in the meantime are durably persisted and re-served by
yhub (hocuspocus lost them with the room). Until yhub grows a kick API,
the manual remediation is yhub's rollback endpoint — per document:
`POST /rollback/{org}/{docid}` with a lib0-encoded body containing
`{"by": "<userid>"}` (see yhub API.md "Rollback"), authenticated as a
user with update ability on the document.
"""
try:
document = models.Document.objects.get(pk=document_id)
except models.Document.DoesNotExist:
logger.error("Document %s does not exists anymore", document_id)
return
documents = models.Document.objects.filter(
path__startswith=document.path, depth__gte=document.depth
).order_by("path")
for doc in documents:
try:
self._reset_connection(doc.id, user_id)
except requests.HTTPError:
logger.error("impossible to reset connections for document %s", doc.id)
def _reset_connection(self, room, user_id=None):
"""
Reset connections of a single room in the collaboration server.
"""
endpoint = "reset-connections"
# room is necessary as a parameter, it is easier to stick to the
# same pod thanks to a parameter
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/?room={room}"
# Note: Collaboration microservice accepts only raw token, which is not recommended
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
if user_id:
headers["X-User-Id"] = user_id
try:
response = requests.post(endpoint_url, headers=headers, timeout=10)
except requests.RequestException as e:
raise requests.HTTPError("Failed to notify WebSocket server.") from e
if response.status_code != 200:
raise requests.HTTPError(
f"Failed to notify WebSocket server. Status code: {response.status_code}, "
f"Response: {response.text}"
)
logger.info(
"reset_connections is a no-op (no yhub kick API), document %s, user %s",
document_id,
user_id,
)
# pylint: disable=unused-argument
def get_document_connection_info(self, room, session_key):
"""
Get the connection info for a document.
TODO(yhub): yhub exposes no connection-info API, so pretend nobody is
connected. Callers fall back to the cache-lock no-websocket path.
"""
endpoint = "get-connections"
querystring = {
"room": room,
"sessionKey": session_key,
}
endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/"
headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET}
try:
response = requests.get(
endpoint_url, headers=headers, params=querystring, timeout=10
)
except requests.RequestException as e:
raise requests.HTTPError("Failed to get document connection info.") from e
if response.status_code == 200:
result = response.json()
return result.get("count", 0), result.get("exists", False)
if response.status_code == 404:
return 0, False
raise requests.HTTPError(
f"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}"
)
return 0, False
@@ -3,7 +3,6 @@
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
@@ -11,22 +10,13 @@ from core import factories
pytestmark = pytest.mark.django_db
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
@pytest.mark.parametrize("role", ["editor", "reader"])
def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only, role):
"""Anonymous users can edit documents when link_role is editor."""
document = factories.DocumentFactory(link_reach="public", link_role=role)
client = APIClient()
session_key = client.session.session_key
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
@@ -35,10 +25,8 @@ def test_api_documents_can_edit_anonymous(settings, ws_not_connected_ready_only,
else:
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
@pytest.mark.parametrize("ws_not_connected_ready_only", [True, False])
def test_api_documents_can_edit_authenticated_no_websocket(
settings, ws_not_connected_ready_only
@@ -50,19 +38,10 @@ def test_api_documents_can_edit_authenticated_no_websocket(
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = ws_not_connected_ready_only
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -72,10 +51,8 @@ def test_api_documents_can_edit_authenticated_no_websocket(
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == (1 if ws_not_connected_ready_only else 0)
@responses.activate
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
settings,
):
@@ -86,18 +63,10 @@ def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -107,45 +76,13 @@ def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
# TODO(yhub): removed test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block edition.
# Re-add the test once yhub exposes a connection-info API.
@responses.activate
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
A user not connected to the websocket and another user is connected to the websocket,
the document can not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.get(
f"/api/v1.0/documents/{document.id!s}/can-edit/",
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_user_connected_to_websocket(settings):
"""
A user connected to the websocket, the document can be updated.
@@ -153,18 +90,10 @@ def test_api_documents_can_edit_user_connected_to_websocket(settings):
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -174,10 +103,8 @@ def test_api_documents_can_edit_user_connected_to_websocket(settings):
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -188,18 +115,10 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -209,10 +128,7 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -223,18 +139,10 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -245,10 +153,8 @@ def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_webs
assert response.json() == {"can_edit": False}
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found(
settings,
):
@@ -259,18 +165,10 @@ def test_api_documents_can_edit_websocket_server_room_not_found(
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -280,10 +178,7 @@ def test_api_documents_can_edit_websocket_server_room_not_found(
assert response.status_code == 200
assert response.json() == {"can_edit": True}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_can_edit_websocket_server_room_not_found_other_already_editing(
settings,
):
@@ -294,18 +189,10 @@ def test_api_documents_can_edit_websocket_server_room_not_found_other_already_ed
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -314,5 +201,3 @@ def test_api_documents_can_edit_websocket_server_room_not_found_other_already_ed
)
assert response.status_code == 200
assert response.json() == {"can_edit": False}
assert ws_resp.call_count == 1
@@ -11,7 +11,6 @@ from django.core.files.storage import default_storage
import pycrdt
import pytest
import responses
from rest_framework import status
from rest_framework.test import APIClient
@@ -254,7 +253,6 @@ def test_api_documents_content_update_link_editor():
assert models.Document.objects.filter(id=document.id).exists()
@responses.activate
def test_api_documents_content_update_authenticated_no_websocket(settings):
"""
When a user updates the document content, not connected to the websocket and is the first
@@ -267,14 +265,7 @@ def test_api_documents_content_update_authenticated_no_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -285,10 +276,8 @@ def test_api_documents_content_update_authenticated_no_websocket(settings):
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_content_update_authenticated_no_websocket_user_already_editing(
settings,
):
@@ -299,18 +288,10 @@ def test_api_documents_content_update_authenticated_no_websocket_user_already_ed
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -320,46 +301,15 @@ def test_api_documents_content_update_authenticated_no_websocket_user_already_ed
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_content_update_no_websocket_other_user_connected_to_websocket(
settings,
):
"""
When a user updates document content without websocket and another user is connected
to the websocket, the update should be denied.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/content/",
{"content": get_sample_ydoc(), "websocket": False},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# TODO(yhub): removed
# test_api_documents_content_update_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the update.
# Re-add the test once yhub exposes a connection-info API.
@responses.activate
def test_api_documents_content_update_user_connected_to_websocket(settings):
"""
When a user updates document content and is connected to the websocket,
@@ -372,14 +322,7 @@ def test_api_documents_content_update_user_connected_to_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -389,11 +332,11 @@ def test_api_documents_content_update_user_connected_to_websocket(settings):
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
@responses.activate
def test_api_documents_content_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -408,14 +351,7 @@ def test_api_documents_content_update_websocket_server_unreachable_fallback_to_n
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -426,10 +362,8 @@ def test_api_documents_content_update_websocket_server_unreachable_fallback_to_n
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -440,18 +374,10 @@ def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocke
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -461,10 +387,8 @@ def test_api_content_update_websocket_server_unreachable_fallback_to_no_websocke
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_content_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -475,18 +399,10 @@ def test_api_content_update_websocket_server_room_not_found_fallback_to_no_webso
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
django_cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -496,10 +412,8 @@ def test_api_content_update_websocket_server_room_not_found_fallback_to_no_webso
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert django_cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_content_update_force_websocket_param_to_true(settings):
"""
When the websocket parameter is set to true, the content should be updated without any check.
@@ -507,18 +421,10 @@ def test_api_documents_content_update_force_websocket_param_to_true(settings):
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -529,10 +435,8 @@ def test_api_documents_content_update_force_websocket_param_to_true(settings):
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_content_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the content should be updated without any check.
@@ -540,18 +444,10 @@ def test_api_documents_content_update_feature_flag_disabled(settings):
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
@@ -562,7 +458,6 @@ def test_api_documents_content_update_feature_flag_disabled(settings):
assert response.status_code == status.HTTP_204_NO_CONTENT
assert get_s3_content(document) == get_sample_ydoc()
assert django_cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
def test_api_documents_content_upadte_invalid_yjs_doc():
@@ -10,7 +10,6 @@ from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -304,7 +303,6 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
assert value == new_document_values[key]
@responses.activate
def test_api_documents_update_authenticated_no_websocket(settings):
"""
When a user updates the document, not connected to the websocket and is the first to update,
@@ -321,15 +319,7 @@ def test_api_documents_update_authenticated_no_websocket(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -344,10 +334,8 @@ def test_api_documents_update_authenticated_no_websocket(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings):
"""
When a user updates the document, not connected to the websocket and is not the first to update,
@@ -356,7 +344,6 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -364,14 +351,7 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -383,49 +363,13 @@ def test_api_documents_update_authenticated_no_websocket_user_already_editing(se
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
# TODO(yhub): removed test_api_documents_update_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the update.
# Re-add the test once yhub exposes a connection-info API.
@responses.activate
def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user updates the document, not connected to the websocket and another user is connected
to the websocket, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_document_values = serializers.DocumentSerializer(
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
new_document_values,
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_user_connected_to_websocket(settings):
"""
When a user updates the document, connected to the websocket, the document should be updated.
@@ -441,14 +385,7 @@ def test_api_documents_update_user_connected_to_websocket(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -462,11 +399,11 @@ def test_api_documents_update_user_connected_to_websocket(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -485,14 +422,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -507,10 +437,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -521,7 +449,6 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -529,14 +456,7 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -548,10 +468,8 @@ def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websoc
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -562,7 +480,6 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -570,14 +487,7 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -589,18 +499,15 @@ def test_api_documents_update_websocket_server_room_not_found_fallback_to_no_web
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_update_force_websocket_param_to_true(settings):
def test_api_documents_update_force_websocket_param_to_true():
"""
When the websocket parameter is set to true, the document should be updated without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -608,13 +515,6 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = True
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -629,10 +529,8 @@ def test_api_documents_update_force_websocket_param_to_true(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_update_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the document should be updated without any check.
@@ -640,7 +538,6 @@ def test_api_documents_update_feature_flag_disabled(settings):
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
@@ -648,14 +545,7 @@ def test_api_documents_update_feature_flag_disabled(settings):
instance=factories.DocumentFactory()
).data
new_document_values["websocket"] = False
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -670,7 +560,6 @@ def test_api_documents_update_feature_flag_disabled(settings):
document.refresh_from_db()
assert document.path == old_path
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
@@ -968,7 +857,6 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
assert document_values[key] == old_document_values[key]
@responses.activate
def test_api_documents_patch_authenticated_no_websocket(settings):
"""
When a user patches the document, not connected to the websocket and is the first to update,
@@ -981,14 +869,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1006,10 +887,8 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_authenticated_no_websocket_user_already_editing(settings):
"""
When a user patches the document, not connected to the websocket and is not the first to
@@ -1018,18 +897,10 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1041,45 +912,13 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert ws_resp.call_count == 1
# TODO(yhub): removed test_api_documents_patch_no_websocket_other_user_connected_to_websocket
# here. yhub has no connection-info API: get_document_connection_info is stubbed to report
# nobody connected, so another user connected to the websocket can no longer block the patch.
# Re-add the test once yhub exposes a connection-info API.
@responses.activate
def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(settings):
"""
When a user patches the document, not connected to the websocket and another user is connected
to the websocket, the document should not be updated.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
assert cache.get(f"docs:no-websocket:{document.id}") is None
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"title": "new title"},
format="json",
)
assert response.status_code == 403
assert response.json() == {"detail": "You are not allowed to edit this document."}
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_user_connected_to_websocket(settings):
"""
When a user patches the document while connected to the websocket, the document should be
@@ -1092,14 +931,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1116,11 +948,11 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even though the user is connected.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket(
settings,
):
@@ -1135,14 +967,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1160,10 +985,8 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websocket_other_users(
settings,
):
@@ -1174,18 +997,10 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1197,10 +1012,8 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_websocket_other_users(
settings,
):
@@ -1211,18 +1024,10 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=404)
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
@@ -1234,29 +1039,18 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
assert response.status_code == 403
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
assert ws_resp.call_count == 1
@responses.activate
def test_api_documents_patch_force_websocket_param_to_true(settings):
def test_api_documents_patch_force_websocket_param_to_true():
"""
When the websocket parameter is set to true, the patch should be applied without any check.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1273,10 +1067,8 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@responses.activate
def test_api_documents_patch_feature_flag_disabled(settings):
"""
When the feature flag is disabled, the patch should be applied without any check.
@@ -1284,18 +1076,10 @@ def test_api_documents_patch_feature_flag_disabled(settings):
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = False
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, status=500)
assert cache.get(f"docs:no-websocket:{document.id}") is None
old_path = document.path
@@ -1313,7 +1097,6 @@ def test_api_documents_patch_feature_flag_disabled(settings):
assert document.path == old_path
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@pytest.mark.parametrize("via", VIA)
@@ -1358,7 +1141,6 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
)
@responses.activate
def test_api_documents_patch_empty_body(settings):
"""
Test when data is empty the document should not be updated.
@@ -1373,14 +1155,7 @@ def test_api_documents_patch_empty_body(settings):
document = factories.DocumentFactory(users=[(user, "owner")], creator=user)
document_updated_at = document.updated_at
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
settings.COLLABORATION_WS_NOT_CONNECTED_READ_ONLY = True
endpoint_url = (
f"{settings.COLLABORATION_API_URL}get-connections/"
f"?room={document.id}&sessionKey={session_key}"
)
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
assert cache.get(f"docs:no-websocket:{document.id}") is None
@@ -1398,5 +1173,6 @@ def test_api_documents_patch_empty_body(settings):
new_document_values = serializers.DocumentSerializer(instance=document).data
assert new_document_values == old_document_values
assert document_updated_at == document.updated_at
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
# TODO(yhub): the stubbed connection info reports nobody connected, so the
# no-websocket cache lock is taken even for an empty body.
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
@@ -9,7 +9,6 @@ because the resource server viewsets inherit from the api viewsets.
from django.test import override_settings
import pytest
import responses
from rest_framework.test import APIClient
from core import factories, models
@@ -504,7 +503,6 @@ def test_external_api_document_accesses_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
@@ -525,19 +523,6 @@ def test_external_api_document_accesses_update_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
old_values = serializers.DocumentAccessSerializer(instance=access).data
# Update only the role field
@@ -573,7 +558,6 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
user_token,
resource_server_backend,
user_specific_sub,
settings,
):
"""
A user who is related to a document SHOULD be allowed to update
@@ -594,19 +578,6 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.patch(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/",
data={"role": models.RoleChoices.EDITOR},
@@ -635,7 +606,7 @@ def test_external_api_document_accesses_partial_update_can_be_allowed(
}
)
def test_external_api_documents_accesses_delete_can_be_allowed(
user_token, resource_server_backend, user_specific_sub, settings
user_token, resource_server_backend, user_specific_sub
):
"""
Connected users SHOULD be allowed to delete an access for
@@ -661,19 +632,6 @@ def test_external_api_documents_accesses_delete_can_be_allowed(
document=document, user=other_user, role=models.RoleChoices.READER
)
# Add the reset-connections endpoint to the existing mock
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document.id}"
)
resource_server_backend.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
response = client.delete(
f"/external_api/v1.0/documents/{document.id!s}/accesses/{other_access.id!s}/",
)
@@ -60,8 +60,6 @@ def test_external_api_documents_link_configuration_not_allowed(
],
},
},
COLLABORATION_API_URL="http://example.com/",
COLLABORATION_SERVER_SECRET="secret-token",
)
@patch("core.api.viewsets.reset_service_connections_in_cascade.delay")
def test_external_api_documents_link_configuration_can_be_allowed(
@@ -3,342 +3,28 @@ This module contains tests for the CollaborationService class in the
core.services.collaboration_services module.
"""
import json
import logging
import re
from contextlib import contextmanager
from unittest import mock
from uuid import uuid4
from django.core.exceptions import ImproperlyConfigured
import pytest
import requests
import responses
from core import factories, models
from core.services.collaboration_services import CollaborationService
# pylint: disable=protected-access
@pytest.fixture(name="mock_reset_connections")
def mock_reset_connections_fixture(settings):
def test_reset_connections_makes_no_http_call():
"""
Creates a context manager to mock the reset-connections endpoint for collaboration services.
Args:
settings: A settings object that contains the configuration for the collaboration API.
Returns:
A context manager function that mocks the reset-connections endpoint.
The context manager function takes the following parameters:
document_id (str): The ID of the document for which connections are being reset.
user_id (str, optional): The ID of the user making the request. Defaults to None.
Usage:
with mock_reset_connections(settings)(document_id, user_id) as mock:
# Your test code here
The context manager performs the following actions:
- Mocks the reset-connections endpoint using responses.RequestsMock.
- Sets the COLLABORATION_API_URL and COLLABORATION_SERVER_SECRET in the settings.
- Verifies that the reset-connections endpoint is called exactly once.
- Checks that the request URL and headers are correct.
- If user_id is provided, checks that the X-User-Id header is correct.
TODO(yhub): yhub has no kick API, so reset_connections is a no-op. It must
neither make any HTTP call nor raise, even without any collaboration
settings configured.
"""
@contextmanager
def _mock_reset_connections(document_id, user_id=None):
with responses.RequestsMock() as rsps:
# Mock the reset-connections endpoint
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
endpoint_url = (
f"{settings.COLLABORATION_API_URL}reset-connections/?room={document_id}"
)
rsps.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
yield
assert len(rsps.calls) == 1, (
"Expected one call to reset-connections endpoint"
)
request = rsps.calls[0].request
assert request.url == endpoint_url, f"Unexpected URL called: {request.url}"
assert (
request.headers.get("Authorization")
== settings.COLLABORATION_SERVER_SECRET
), "Incorrect Authorization header"
if user_id:
assert request.headers.get("X-User-Id") == user_id, (
"Incorrect X-User-Id header"
)
return _mock_reset_connections
with responses.RequestsMock():
CollaborationService().reset_connections("document-id")
CollaborationService().reset_connections("document-id", user_id="user-id")
def test_init_without_api_url(settings):
"""Test that ImproperlyConfigured is raised when COLLABORATION_API_URL is None."""
settings.COLLABORATION_API_URL = None
with pytest.raises(ImproperlyConfigured):
CollaborationService()
def test_init_with_api_url(settings):
"""Test that the service initializes correctly when COLLABORATION_API_URL is set."""
settings.COLLABORATION_API_URL = "http://example.com/"
service = CollaborationService()
assert isinstance(service, CollaborationService)
@responses.activate
def test_reset_connection_with_user_id(settings):
"""Test _reset_connection with a provided user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(responses.POST, endpoint_url, json={}, status=200)
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") == "user123"
@responses.activate
def test_reset_connection_without_user_id(settings):
"""Test _reset_connection without a user_id."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = None
endpoint_url = "http://example.com/reset-connections/?room=" + room
responses.add(
responses.POST,
endpoint_url,
json={},
status=200,
)
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
request = responses.calls[0].request
assert request.url == endpoint_url
assert request.headers.get("Authorization") == "secret-token"
assert request.headers.get("X-User-Id") is None
@responses.activate
def test_reset_connection_non_200_response(settings):
"""Test that an HTTPError is raised when the response status is not 200."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections/?room=" + room
response_body = {"error": "Internal Server Error"}
responses.add(responses.POST, endpoint_url, json=response_body, status=500)
expected_exception_message = re.escape(
"Failed to notify WebSocket server. Status code: 500, Response: "
) + re.escape(json.dumps(response_body))
with pytest.raises(requests.HTTPError, match=expected_exception_message):
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
@responses.activate
def test_reset_connection_request_exception(settings):
"""Test that an HTTPError is raised when a RequestException occurs."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
service = CollaborationService()
room = "room1"
user_id = "user123"
endpoint_url = "http://example.com/reset-connections?room=" + room
responses.add(
responses.POST,
endpoint_url,
body=requests.exceptions.ConnectionError("Network error"),
)
with pytest.raises(requests.HTTPError, match="Failed to notify WebSocket server."):
service._reset_connection(room, user_id)
assert len(responses.calls) == 1
@pytest.fixture(name="collaboration_service")
def collaboration_service_fixture(settings):
"""Return a configured CollaborationService instance."""
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
return CollaborationService()
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_document_does_not_exist(
mock_reset_connection,
collaboration_service,
caplog,
):
def test_get_document_connection_info_makes_no_http_call():
"""
When the document does not exist anymore, an error is logged and no
connection is reset.
TODO(yhub): yhub has no connection-info API, so get_document_connection_info
always reports nobody connected, without making any HTTP call.
"""
unknown_id = uuid4()
with caplog.at_level(logging.ERROR, logger="core.services.collaboration_services"):
collaboration_service.reset_connections(unknown_id)
mock_reset_connection.assert_not_called()
assert f"Document {unknown_id} does not exists anymore" in caplog.text
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_single_document(
mock_reset_connection,
collaboration_service,
):
"""A document without descendants should have its own connections reset."""
document = factories.DocumentFactory()
collaboration_service.reset_connections(document.id)
mock_reset_connection.assert_called_once_with(document.id, None)
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_cascade_on_document_and_descendants(
mock_reset_connection,
collaboration_service,
):
"""
The document itself and every one of its descendants should be reset,
ordered by path.
"""
root = factories.DocumentFactory()
child1 = factories.DocumentFactory(parent=root)
child2 = factories.DocumentFactory(parent=root)
grandchild = factories.DocumentFactory(parent=child1)
collaboration_service.reset_connections(root.id)
expected_ids = [
doc.id
for doc in models.Document.objects.filter(
path__startswith=root.path, depth__gte=root.depth
).order_by("path")
]
assert set(expected_ids) == {root.id, child1.id, child2.id, grandchild.id}
called_ids = [call.args[0] for call in mock_reset_connection.call_args_list]
assert called_ids == expected_ids
assert mock_reset_connection.call_count == 4
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_starts_from_a_sub_document(
mock_reset_connection,
collaboration_service,
):
"""
When called on a sub-document, only that sub-document and its own
descendants should be reset, not its ancestors or siblings.
"""
root = factories.DocumentFactory()
child = factories.DocumentFactory(parent=root)
sibling = factories.DocumentFactory(parent=root)
grandchild = factories.DocumentFactory(parent=child)
collaboration_service.reset_connections(child.id)
called_ids = {call.args[0] for call in mock_reset_connection.call_args_list}
assert called_ids == {child.id, grandchild.id}
assert root.id not in called_ids
assert sibling.id not in called_ids
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_forwards_user_id(
mock_reset_connection,
collaboration_service,
):
"""The provided user_id should be forwarded to every reset call."""
root = factories.DocumentFactory()
factories.DocumentFactory(parent=root)
user_id = str(uuid4())
collaboration_service.reset_connections(root.id, user_id=user_id)
assert mock_reset_connection.call_count == 2
for call in mock_reset_connection.call_args_list:
assert call.args[1] == user_id
@pytest.mark.django_db
@mock.patch.object(CollaborationService, "_reset_connection")
def test_reset_connections_continues_on_http_error(
mock_reset_connection,
collaboration_service,
caplog,
):
"""
An HTTPError raised while resetting one document should be logged and must
not prevent the remaining documents from being processed.
"""
root = factories.DocumentFactory()
child1 = factories.DocumentFactory(parent=root)
child2 = factories.DocumentFactory(parent=root)
ordered_docs = list(
models.Document.objects.filter(
path__startswith=root.path, depth__gte=root.depth
).order_by("path")
)
failing_doc = ordered_docs[1]
def _side_effect(room, _user_id=None):
if room == failing_doc.id:
raise requests.HTTPError("boom")
mock_reset_connection.side_effect = _side_effect
with caplog.at_level(logging.ERROR, logger="core.services.collaboration_services"):
collaboration_service.reset_connections(root.id)
assert mock_reset_connection.call_count == 3
called_ids = [call.args[0] for call in mock_reset_connection.call_args_list]
assert set(called_ids) == {root.id, child1.id, child2.id}
assert (
f"impossible to reset connections for document {failing_doc.id}" in caplog.text
)
with responses.RequestsMock():
assert CollaborationService().get_document_connection_info(
"room", "session-key"
) == (0, False)
@@ -5,10 +5,6 @@ core.tasks.access module.
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
import pytest
from core.tasks.access import reset_service_connections_in_cascade
@@ -33,16 +29,3 @@ def test_reset_service_connections_defaults_user_id_to_none(mock_service):
mock_service.return_value.reset_connections.assert_called_once_with(
"document-id", None
)
@mock.patch(
"core.tasks.access.CollaborationService",
side_effect=ImproperlyConfigured("Collaboration configuration not set"),
)
def test_reset_service_connections_propagates_improperly_configured(mock_service): # pylint: disable=unused-argument
"""
If the collaboration service is not configured, instantiating it raises
ImproperlyConfigured, which should propagate out of the task.
"""
with pytest.raises(ImproperlyConfigured):
reset_service_connections_in_cascade("document-id")
+4
View File
@@ -512,9 +512,13 @@ class Base(Configuration):
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Collaboration
# TODO(yhub): unused since the yhub migration — yhub has no management API
# (reset-connections / get-connections). Kept until a yhub kick and
# connection-info API exist and CollaborationService is reinstated.
COLLABORATION_API_URL = values.Value(
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
)
# TODO(yhub): unused since the yhub migration, see COLLABORATION_API_URL.
COLLABORATION_SERVER_SECRET = SecretFileValue(
None, environ_name="COLLABORATION_SERVER_SECRET", environ_prefix=None
)
+1 -1
View File
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
CUSTOM_SIGN_IN=false
IS_INSTANCE=false
+1 -1
View File
@@ -1,7 +1,7 @@
PORT=3000
BASE_URL=http://localhost:3000
BASE_API_URL=http://localhost:8071/api/v1.0
COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/
COLLABORATION_WS_URL=ws://localhost:3002/ws/docs
MEDIA_BASE_URL=http://localhost:8083
IS_INSTANCE=false
CUSTOM_SIGN_IN=false
@@ -82,9 +82,9 @@ test.describe('Config', () => {
.click();
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}`);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
});
test('it checks FRONTEND_CSS_URL config', async ({ page }) => {
@@ -15,14 +15,10 @@ 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 }) => {
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -32,42 +28,21 @@ test.describe('Doc Collaboration', () => {
})
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
const framesentPromise = webSocket.waitForEvent('framesent');
await writeInEditor({ page, text: 'Hello World' });
let framesent = await framesentPromise;
const framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByTestId('doc-visibility');
// When the visibility is changed, the ws should close the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page.getByRole('menuitemradio', { name: 'Connected' }).click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
expect(wsClose.isClosed()).toBeTruthy();
// Check the ws is connected again
webSocket = await page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
});
framesentPromise = webSocket.waitForEvent('framesent');
framesent = await framesentPromise;
expect(framesent.payload).not.toBeNull();
// TODO(yhub): re-add the close/reconnect check (the backend closed the
// connection when the doc visibility changed) once yhub exposes a kick
// API - `reset_connections` is currently a no-op so the server never
// closes the connection.
});
test('it cannot edit if viewer but see and can get resources', async ({
@@ -136,20 +111,24 @@ test.describe('Doc Collaboration', () => {
await cleanup();
});
test('it checks block editing when not connected to collab server', async ({
// TODO(yhub): re-enable when yhub exposes a connection-info API - the test
// asserts `can_edit=false` while another user is connected to the
// collaborative server, but `get_document_connection_info` is currently
// stubbed to report no connections.
test.skip('it checks block editing when not connected to collab server', async ({
page,
browserName,
}) => {
test.slow();
/**
* The good port is 4444, but we want to simulate a not connected
* The good port is 3002, but we want to simulate a not connected
* collaborative server.
* So we use a port that is not used by the collaborative server.
* The server will not be able to connect to the collaborative server.
*/
await overrideConfig(page, {
COLLABORATION_WS_URL: 'ws://localhost:5555/collaboration/ws/',
COLLABORATION_WS_URL: 'ws://localhost:5555/ws/docs',
COLLABORATION_WS_NOT_CONNECTED_READ_ONLY: true,
});
@@ -211,18 +190,14 @@ test.describe('Doc Collaboration', () => {
const webSocketPromise = otherPage.waitForEvent(
'websocket',
(webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
},
);
await otherPage.goto(urlChildDoc);
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
await verifyDocName(otherPage, childTitle);
@@ -288,9 +263,7 @@ test.describe('Doc Collaboration', () => {
await page.goto('/');
let webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
await page
@@ -301,9 +274,7 @@ test.describe('Doc Collaboration', () => {
.click();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
`${process.env.COLLABORATION_WS_URL}?room=`,
);
expect(webSocket.url()).toContain(`${process.env.COLLABORATION_WS_URL}/`);
// Is connected
let framesentPromise = webSocket.waitForEvent('framesent');
@@ -332,9 +303,7 @@ test.describe('Doc Collaboration', () => {
// Check the ws is connected again
webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket
.url()
.includes(`${process.env.COLLABORATION_WS_URL}?room=`);
return webSocket.url().includes(`${process.env.COLLABORATION_WS_URL}/`);
});
// Simulate the tab becoming visible again
+1 -1
View File
@@ -43,7 +43,6 @@
"@gouvfr-lasuite/cunningham-react": "*",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.27.0",
"@hocuspocus/provider": "3.4.4",
"@lottiefiles/dotlottie-react": "^0.19.6",
"@mantine/core": "9.4.1",
"@mantine/hooks": "9.4.1",
@@ -78,6 +77,7 @@
"use-debounce": "10.1.1",
"uuid": "14.0.1",
"y-protocols": "1.0.7",
"y-websocket": "3.0.0",
"yjs": "*",
"zod": "4.4.3",
"zustand": "5.0.14"
@@ -7,11 +7,12 @@ export const useCollaborationUrl = (room?: string) => {
return;
}
const base =
// The room is appended to the base URL by the provider (y-websocket)
return (
conf?.COLLABORATION_WS_URL ||
(typeof window !== 'undefined'
? `wss://${window.location.host}/collaboration/ws/`
: '');
return `${base}?room=${room}`;
? // TODO(yhub): no prod ingress route yet
`wss://${window.location.host}/ws/docs`
: '')
);
};
@@ -30,13 +30,13 @@ export function useComments(
canComment,
config?.REACTIONS_MAX_PER_COMMENT ?? 0,
),
provider?.document,
provider?.doc,
);
}, [
docId,
canComment,
provider?.awareness,
provider?.document,
provider?.doc,
user?.full_name,
config?.REACTIONS_MAX_PER_COMMENT,
]);
@@ -28,8 +28,8 @@ vi.mock('../../doc-management', async () => {
useIsCollaborativeEditable: () => ({ isEditable: true, isLoading: false }),
useProviderStore: () => ({
provider: {
configuration: { name: 'test-doc-id' },
document: {
roomname: 'test-doc-id',
doc: {
getXmlFragment: () => null,
},
},
@@ -17,11 +17,10 @@ import {
ThreadsSidebar,
useCreateBlockNote,
} from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import type { Awareness } from 'y-protocols/awareness';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -85,7 +84,7 @@ export const blockNoteSchema = (withMultiColumn?.(baseBlockNoteSchema) ||
interface BlockNoteEditorProps {
doc: Doc;
provider: HocuspocusProvider;
provider: WebsocketProvider;
}
export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
@@ -93,7 +92,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { setEditor } = useEditorStore();
const { themeTokens } = useCunninghamTheme();
const refEditorContainer = useRef<HTMLDivElement>(null);
useSaveDoc(doc.id, provider.document);
useSaveDoc(doc.id, provider.doc);
const { i18n, t } = useTranslation();
const langLocalesBN =
@@ -150,8 +149,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider: provider as { awareness?: Awareness | undefined },
fragment: provider.document.getXmlFragment('document-store'),
provider,
fragment: provider.doc.getXmlFragment('document-store'),
user: {
name: cursorName,
color: randomColor(),
@@ -142,16 +142,10 @@ interface DocCoreEditorProps {
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const showContent = !!(isProviderReady && provider?.roomname === doc.id);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
if (skeletonVisible || !isProviderReady || provider?.roomname !== doc.id) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
@@ -165,7 +159,7 @@ export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
initialContent={provider.doc.getXmlFragment('document-store')}
docId={doc.id}
/>
);
@@ -56,6 +56,9 @@ export const useCollaboration = (room: string) => {
* When the provider detects a lost connection, we invalidate the document query to trigger a refetch.
* Because it can be because the user has access to the document that are modified
* (e.g., permissions changed, document deleted, user removed)
* TODO(yhub): this invalidation used to ride on the server-side kick
* (reset-connections); without a kick API a permission change no longer
* triggers a refetch until the connection drops for another reason.
*/
useEffect(() => {
if (hasLostConnection && room) {
@@ -71,7 +74,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
if (!room || broadcastProvider?.document?.guid !== room) {
if (!room || broadcastProvider?.doc.guid !== room) {
return;
}
@@ -80,7 +83,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
}, [addTask, room, queryClient, broadcastProvider?.doc.guid]);
/**
* Set the provider when the collaboration URL and the document content are available.
@@ -1,5 +1,6 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { useDocContentUpdate } from '@/docs/doc-management/api/useDocContentUpdate';
@@ -49,22 +50,19 @@ export const useSaveDoc = (docId: string, yDoc: Y.Doc) => {
) => {
/**
* When the AI edit the doc transaction.local is false,
* so we check if the origin constructor to know where
* so we check the transaction origin to know where
* the transaction comes from.
* "PluginKey" constructor comes from the current user, but transaction.local is more reliable
* "HocuspocusProvider" constructor comes from other users from the collaboration server,
* it seems quite reliable too.
* The AI constructor name seems to not be reliable enough, but by deduction if it's not local
* "PluginKey" origin comes from the current user, but transaction.local is more reliable
* Updates from other users are applied by the collaboration server with
* the provider instance as origin, it seems quite reliable too.
* The AI origin seems to not be reliable enough, but by deduction if it's not local
* and not from other users, it has to be from the AI.
*
* TODO: see if we can get the local changes from the AI
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const transactionOrigin = transaction?.origin?.constructor?.name;
const PROVIDER_ORIGIN_CONSTRUCTOR = 'HocuspocusProvider';
const isAIChange =
!transaction.local && transactionOrigin !== PROVIDER_ORIGIN_CONSTRUCTOR;
!transaction.local &&
!(transaction.origin instanceof WebsocketProvider);
/**
* notifySubscribers generate a transaction that can be
@@ -70,14 +70,12 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
mutationFn: async (variables) => {
// Save the document if we can first, to ensure the latest state is duplicated
const canSave =
variables.canSave &&
provider &&
provider.document.guid === variables.docId;
variables.canSave && provider && provider.doc.guid === variables.docId;
if (canSave) {
await updateDocContent({
id: variables.docId,
content: toBase64(Y.encodeStateAsUpdate(provider.document)),
content: toBase64(Y.encodeStateAsUpdate(provider.doc)),
});
}
@@ -6,11 +6,13 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { useProviderStore } from '../stores';
import { Doc } from '../types';
export interface UpdateDocParams {
id: Doc['id'];
title?: string;
websocket?: boolean;
}
export const updateDoc = async ({
@@ -38,7 +40,16 @@ type UseUpdateDoc = UseMutationOptions<Doc, APIError, UpdateDocParams> & {
export function useUpdateDoc(queryConfig?: UseUpdateDoc) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, UpdateDocParams>({
mutationFn: updateDoc,
/**
* Tell the backend when we hold a live collaboration connection,
* otherwise its no-websocket cache lock blocks the update while
* another user is connected.
*/
mutationFn: (params) =>
updateDoc({
...(useProviderStore.getState().isSynced ? { websocket: true } : {}),
...params,
}),
...queryConfig,
onSuccess: (data, variables, onMutateResult, context) => {
queryConfig?.listInvalidQueries?.forEach((queryKey) => {
@@ -1,5 +1,4 @@
import { CloseEvent } from '@hocuspocus/common';
import { HocuspocusProvider, WebSocketStatus } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -10,12 +9,12 @@ export interface UseCollaborationStore {
providerUrl: string,
storeId: string,
initialDoc?: Base64,
) => HocuspocusProvider;
) => WebsocketProvider;
destroyProvider: () => void;
setReady: (value: boolean) => void;
pauseForInactivity: () => void;
resumeFromInactivity: () => void;
provider: HocuspocusProvider | undefined;
provider: WebsocketProvider | undefined;
isConnected: boolean;
isReady: boolean;
isSynced: boolean;
@@ -33,18 +32,14 @@ const defaultValues = {
isPausedForInactivity: false,
};
type ExtendedCloseEvent = CloseEvent & { wasClean: boolean };
/**
* When a massive simultaneous disconnection occurs (e.g. infra restart), all
* clients would reconnect and invalidate their queries at exactly the same
* time, causing a possible DB spike. Adding random jitter spreads these events over a
* time window so the load is absorbed gradually.
*/
const RECONNECT_BASE_DELAY_MS = 1000;
const RECONNECT_JITTER_MAX_MS = 3000;
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined;
let lostConnectionTimeout: ReturnType<typeof setTimeout> | undefined;
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
@@ -58,104 +53,54 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new HocuspocusProvider({
url: wsUrl,
name: storeId,
document: doc,
onDisconnect(data) {
// Skip reconnect when the disconnect was triggered by inactivity:
// reconnection only happens once the user becomes active again.
if (get().isPausedForInactivity) {
return;
}
// Attempt to reconnect if the disconnection was clean (initiated by the client or server)
if ((data.event as ExtendedCloseEvent).wasClean) {
if (data.event.reason === 'No cookies' && data.event.code === 4001) {
console.error(
'Disconnection due to missing cookies. Not attempting to reconnect.',
);
void provider.disconnect();
set({
isReady: true,
isConnected: false,
});
return;
}
clearTimeout(reconnectTimeout);
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
reconnectTimeout = setTimeout(
() => void provider.connect(),
RECONNECT_BASE_DELAY_MS + Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
},
onAuthenticationFailed() {
set({ isReady: true, isConnected: false });
},
onAuthenticated() {
set({ isReady: true, isConnected: true });
},
onStatus: ({ status }) => {
const isConnected = status === WebSocketStatus.Connected;
const wasConnected = get().isConnected;
if (isConnected) {
clearTimeout(lostConnectionTimeout);
}
// If we were previously connected and now we're not,
// we might have lost the connection
else if (wasConnected && !get().isPausedForInactivity) {
clearTimeout(lostConnectionTimeout);
// Jitter spreading for reconnection attempts
// Math.random() generates a random delay to avoid all clients
// reconnecting at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
}
set((state) => {
/**
* status === WebSocketStatus.Connected does not mean we are totally connected
* because authentication can still be in progress and failed
* So we only update isConnected when we lose the connection
*/
const connected =
status !== WebSocketStatus.Connected
? {
isConnected: false,
}
: undefined;
return {
...connected,
isReady: state.isReady || status === WebSocketStatus.Disconnected,
};
});
},
onSynced: ({ state }) => {
set({ isSynced: state, isReady: true });
},
onClose(data) {
/**
* Handle the "Reset Connection" event from the server
* This is triggered when the server wants to reset the connection
* for clients in the room.
* A disconnect is made automatically but it takes time to be triggered,
* so we force the disconnection here.
*/
if (data.event.code === 1000) {
provider.disconnect();
}
},
const provider = new WebsocketProvider(wsUrl, storeId, doc, {
// BroadcastChannel would bypass server auth
disableBc: true,
// The default 2.5s backoff would hammer the backend with auth fetches
// on permanently-failing sockets
maxBackoffTime: 30000,
// Guarantees inbound traffic for y-websocket's 30s no-traffic watchdog
resyncInterval: 20000,
});
provider.on('status', ({ status }) => {
// 'connecting' must be ignored: it fires on every backoff retry.
// 'disconnected' is handled via 'connection-close' (it never fires
// for sockets that failed to open).
if (status === 'connected') {
clearTimeout(lostConnectionTimeout);
// An open socket means we are authenticated (auth happens at upgrade)
set({ isConnected: true, isReady: true });
}
});
provider.on('sync', (isSynced: boolean) => {
set({ isSynced, isReady: true });
});
// Fires on every close AND every failed connection attempt
// (an auth failure surfaces as an upgrade-level 401, close code 1006).
provider.on('connection-close', () => {
// Skip when the disconnect was triggered by inactivity:
// reconnection only happens once the user becomes active again.
if (get().isPausedForInactivity) {
return;
}
// The editor renders from the last snapshot while y-websocket retries
set({ isConnected: false, isReady: true });
clearTimeout(lostConnectionTimeout);
// Jitter spreading: Math.random() generates a random delay to avoid
// all clients invalidating their queries at the same time
lostConnectionTimeout = setTimeout(
() => set({ hasLostConnection: true }),
Math.random() * RECONNECT_JITTER_MAX_MS,
);
});
// TODO(yhub): re-add kick handling when yhub exposes a kick API (was onClose code 1000).
set({
provider,
});
@@ -163,12 +108,19 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
return provider;
},
destroyProvider: () => {
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
const provider = get().provider;
if (provider) {
/**
* destroy() emits 'connection-close' synchronously before removing
* listeners, which re-arms lostConnectionTimeout: it must be cleared
* after, or a stale "connection lost" banner flashes on the next doc.
*/
provider.destroy();
// y-websocket never destroys the awareness: its interval would leak
provider.awareness.destroy();
provider.doc.destroy();
}
clearTimeout(lostConnectionTimeout);
set(defaultValues);
},
@@ -177,7 +129,6 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
if (get().isPausedForInactivity) {
return;
}
clearTimeout(reconnectTimeout);
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: true, hasLostConnection: false });
get().provider?.disconnect();
@@ -188,7 +139,7 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
}
clearTimeout(lostConnectionTimeout);
set({ isPausedForInactivity: false });
void get().provider?.connect();
get().provider?.connect();
},
resetLostConnection: () => set({ hasLostConnection: false }),
}));
@@ -58,11 +58,7 @@ export const ModalConfirmationVersion = ({
return;
}
revertUpdate(
provider.document,
provider.document,
base64ToYDoc(version.content),
);
revertUpdate(provider.doc, provider.doc, base64ToYDoc(version.content));
threadStore?.refreshThreads();
@@ -19,8 +19,7 @@ export const RightPanel = () => {
const { setIsPanelOpen, isPanelOpen, activePanel } = useRightPanelStore();
const { isMobile } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const isProviderReady =
isReady && provider && provider?.configuration.name === doc?.id;
const isProviderReady = isReady && provider && provider?.roomname === doc?.id;
const { restoreFocus } = useFocusStore();
/**
@@ -1,4 +1,4 @@
import { HocuspocusProvider } from '@hocuspocus/provider';
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
import { create } from 'zustand';
@@ -6,10 +6,10 @@ interface BroadcastState {
addTask: (taskLabel: string, action: () => void) => void;
broadcast: (taskLabel: string) => void;
cleanupBroadcast: () => void;
getBroadcastProvider: () => HocuspocusProvider | undefined;
handleProviderSync: () => void;
provider?: HocuspocusProvider;
setBroadcastProvider: (provider: HocuspocusProvider) => void;
getBroadcastProvider: () => WebsocketProvider | undefined;
handleProviderSync: (isSynced: boolean) => void;
provider?: WebsocketProvider;
setBroadcastProvider: (provider: WebsocketProvider) => void;
setTask: (
taskLabel: string,
task: Y.Array<string>,
@@ -34,13 +34,18 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
// Clean up old provider listeners
const oldProvider = get().provider;
if (oldProvider) {
oldProvider.off('synced', get().handleProviderSync);
oldProvider.off('sync', get().handleProviderSync);
}
provider.on('synced', get().handleProviderSync);
provider.on('sync', get().handleProviderSync);
set({ provider });
},
handleProviderSync: () => {
handleProviderSync: (isSynced) => {
// 'sync' fires on both edges; only re-register the tasks once synced
if (!isSynced) {
return;
}
const tasks = get().tasks;
Object.entries(tasks).forEach(([taskLabel, { action }]) => {
get().addTask(taskLabel, action);
@@ -61,10 +66,16 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
return;
}
const task = provider.document.getArray<string>(taskLabel);
const task = provider.doc.getArray<string>(taskLabel);
get().setTask(taskLabel, task, action);
},
setTask: (taskLabel: string, task: Y.Array<string>, action: () => void) => {
// Unobserve the previous observer to avoid leaking one per re-registration
const previousTask = get().tasks[taskLabel];
if (previousTask) {
previousTask.task.unobserve(previousTask.observer);
}
let isInitializing = true;
const observer = (
_event: Y.YArrayEvent<string>,
@@ -102,7 +113,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
cleanupBroadcast: () => {
const provider = get().provider;
if (provider) {
provider.off('synced', get().handleProviderSync);
provider.off('sync', get().handleProviderSync);
}
// Unobserve all document-specific tasks
+1
View File
@@ -46,6 +46,7 @@
"serialize-javascript": "7.0.7",
"typescript": "6.0.3",
"wrap-ansi": "10.0.0",
"y-protocols": "1.0.7",
"yjs": "13.6.31"
},
"packageManager": "yarn@1.22.22"
@@ -1,66 +0,0 @@
import axios from 'axios';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', () => ({
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
Y_PROVIDER_API_KEY: 'test-yprovider-key',
}));
describe('CollaborationBackend', () => {
test('fetchDocument sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-doc-id',
abilities: { retrieve: true, update: true },
},
});
const { fetchDocument } = await import('@/api/collaborationBackend');
const documentId = 'test-document-123';
await fetchDocument({ name: documentId }, { cookie: 'test-cookie' });
expect(axiosGetSpy).toHaveBeenCalledWith(
`http://app-dev:8000/api/v1.0/documents/${documentId}/`,
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
}),
}),
);
axiosGetSpy.mockRestore();
});
test('fetchCurrentUser sends X-Y-Provider-Key header', async () => {
const axiosGetSpy = vi.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: {
id: 'test-user-id',
email: 'test@example.com',
},
});
const { fetchCurrentUser } = await import('@/api/collaborationBackend');
await fetchCurrentUser({
cookie: 'test-cookie',
origin: 'http://localhost:3000',
});
expect(axiosGetSpy).toHaveBeenCalledWith(
'http://app-dev:8000/api/v1.0/users/me/',
expect.objectContaining({
headers: expect.objectContaining({
'X-Y-Provider-Key': 'test-yprovider-key',
cookie: 'test-cookie',
origin: 'http://localhost:3000',
}),
}),
);
axiosGetSpy.mockRestore();
});
});
@@ -1,63 +0,0 @@
import request from 'supertest';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5555,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/?room=test-room')
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(401);
expect(response.body).toStrictEqual({
error: 'Unauthorized: Invalid API Key',
});
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections/')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body).toStrictEqual({ error: 'Room name not provided' });
});
test('POST /collaboration/api/reset-connections?room=[ROOM_ID] with correct API key should reset connections', async () => {
const closeConnectionsMock = vi
.spyOn(hocuspocusServer.hocuspocus, 'closeConnections')
.mockResolvedValue();
const app = initApp();
const response = await request(app)
.post('/collaboration/api/reset-connections?room=test-room')
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toStrictEqual({ message: 'Connections reset' });
expect(closeConnectionsMock).toHaveBeenCalledOnce();
});
});
@@ -1,275 +0,0 @@
import request from 'supertest';
import { v4 as uuid } from 'uuid';
import { describe, expect, test, vi } from 'vitest';
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5556,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
};
});
console.error = vi.fn();
import { COLLABORATION_SERVER_ORIGIN as origin } from '@/env';
import { hocuspocusServer, initApp } from '@/servers';
const apiEndpoint = '/collaboration/api/get-connections/';
describe('Server Tests', () => {
test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'wrong-api-key');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Unauthorized: Invalid API Key');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Room name not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key')
.send({ document_id: 'test-document' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Session key not provided');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => {
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Room not found');
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: true,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing, read only connection', async () => {
const document = await hocuspocusServer.hocuspocus.createDocument(
'test-room',
{},
uuid(),
{ isAuthenticated: true, readOnly: false },
{},
);
document.addConnection({
webSocket: 1,
context: { sessionKey: 'test-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 2,
context: { sessionKey: 'other-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 3,
context: { sessionKey: 'last-session-key' },
document: document,
pongReceived: false,
readOnly: false,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
document.addConnection({
webSocket: 4,
context: { sessionKey: 'session-read-only' },
document: document,
pongReceived: false,
readOnly: true,
request: null,
timeout: 0,
socketId: uuid(),
lock: null,
} as any);
const app = initApp();
const response = await request(app)
.get(`${apiEndpoint}?room=test-room&sessionKey=session-read-only`)
.set('Origin', origin)
.set('Authorization', 'test-secret-api-key');
expect(response.status).toBe(200);
expect(response.body).toEqual({
count: 3,
exists: false,
});
});
});
@@ -1,388 +0,0 @@
import { Server } from 'node:net';
import {
HocuspocusProvider,
HocuspocusProviderWebsocket,
} from '@hocuspocus/provider';
import { v1 as uuidv1, v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from 'vitest';
import WebSocket from 'ws';
const portWS = 6666;
vi.mock('../src/env', async (importOriginal) => {
return {
...(await importOriginal()),
PORT: 5559,
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
COLLABORATION_SERVER_SECRET: 'test-secret-api-key',
COLLABORATION_BACKEND_BASE_URL: 'http://app-dev:8000',
COLLABORATION_LOGGING: 'true',
};
});
vi.mock('../src/api/collaborationBackend', () => ({
fetchCurrentUser: vi.fn(),
fetchDocument: vi.fn(),
}));
console.error = vi.fn();
console.log = vi.fn();
import * as CollaborationBackend from '@/api/collaborationBackend';
import { COLLABORATION_SERVER_ORIGIN as origin, PORT as port } from '@/env';
import { promiseDone } from '@/helpers';
import { hocuspocusServer, initApp } from '@/servers';
describe('Server Tests', () => {
let server: Server;
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
beforeAll(async () => {
server = initApp().listen(port);
await hocuspocusServer.listen(portWS);
});
afterAll(() => {
void hocuspocusServer.destroy();
server.close();
});
test('WebSocket connection with bad origin should be closed', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
headers: {
Origin: 'http://bad-origin.com',
},
});
ws.onclose = () => {
expect(ws.readyState).toBe(ws.CLOSED);
done();
};
return promise;
});
test('WebSocket connection without cookies header should be closed', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const ws = new WebSocket(`ws://localhost:${port}/?room=${room}`, {
headers: {
Origin: origin,
},
});
ws.onclose = () => {
expect(ws.readyState).toBe(ws.CLOSED);
done();
};
return promise;
});
test('WebSocket connection not allowed if room not matching provider name', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const providerName = uuidv4();
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: providerName,
onAuthenticationFailed(data) {
expect(console.log).toHaveBeenCalledWith(
expect.any(String),
' --- ',
'Invalid room name - Probable hacking attempt:',
providerName,
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection not allowed if room is not a valid uuid v4', () => {
const { promise, done } = promiseDone();
const room = uuidv1();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'Room name is not a valid uuid:',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection not allowed if room is not a valid uuid', () => {
const { promise, done } = promiseDone();
const room = 'not-a-valid-uuid';
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'Room name is not a valid uuid:',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection fails if user can not access document', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockRejectedValue(new Error('some error'));
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.error).toHaveBeenLastCalledWith(
'[onConnect]',
'Backend error: Unauthorized',
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
{ name: room },
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
test('WebSocket connection fails if user do not have correct retrieve ability', () => {
const { promise, done } = promiseDone();
const room = uuidv4();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({ abilities: { retrieve: false } } as any);
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
maxAttempts: 1,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onAuthenticationFailed: (data) => {
expect(console.log).toHaveBeenLastCalledWith(
expect.any(String),
' --- ',
'onConnect: Unauthorized to retrieve this document',
room,
);
wsHocus.stopConnectionAttempt();
expect(data.reason).toBe('permission-denied');
expect(fetchDocumentMock).toHaveBeenCalledExactlyOnceWith(
{ name: room },
expect.any(Object),
);
wsHocus.webSocket?.close();
wsHocus.disconnect();
provider.destroy();
wsHocus.destroy();
done();
},
});
provider.attach();
return promise;
});
[true, false].forEach((canEdit) => {
test(`WebSocket connection ${canEdit ? 'can' : 'can not'} edit document`, () => {
const { promise, done } = promiseDone();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: canEdit },
} as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onConnect: () => {
void hocuspocusServer.hocuspocus
.openDirectConnection(room)
.then((connection) => {
connection.document?.getConnections().forEach((connection) => {
expect(connection.readOnly).toBe(!canEdit);
});
void connection.disconnect();
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
{ name: room },
expect.any(Object),
);
done();
});
},
});
provider.attach();
return promise;
});
});
test('Add request header x-user-id if found', () => {
const { promise, done } = promiseDone();
const fetchDocumentMock = vi
.spyOn(CollaborationBackend, 'fetchDocument')
.mockResolvedValue({
abilities: { retrieve: true, update: true },
} as any);
const fetchCurrentUserMock = vi
.spyOn(CollaborationBackend, 'fetchCurrentUser')
.mockResolvedValue({ id: 'test-user-id' } as any);
const room = uuidv4();
const wsHocus = new HocuspocusProviderWebsocket({
url: `ws://localhost:${portWS}/?room=${room}`,
WebSocketPolyfill: WebSocket,
});
const provider = new HocuspocusProvider({
websocketProvider: wsHocus,
name: room,
onConnect: () => {
const document = hocuspocusServer.hocuspocus.documents.get(room);
if (document) {
document.getConnections().forEach((connection) => {
expect(connection.context.userId).toBe('test-user-id');
});
}
provider.destroy();
wsHocus.destroy();
expect(fetchDocumentMock).toHaveBeenCalledWith(
{ name: room },
expect.any(Object),
);
expect(fetchCurrentUserMock).toHaveBeenCalled();
done();
},
});
provider.attach();
return promise;
});
});
+1 -10
View File
@@ -18,26 +18,18 @@
"dependencies": {
"@blocknote/core": "0.51.4",
"@blocknote/server-util": "0.51.4",
"@hocuspocus/server": "3.4.4",
"@sentry/node": "10.65.0",
"@sentry/profiling-node": "10.65.0",
"@tiptap/extensions": "*",
"axios": "1.18.1",
"cors": "2.8.6",
"express": "5.2.1",
"express-ws": "5.0.2",
"uuid": "14.0.1",
"y-protocols": "1.0.7",
"yjs": "*"
},
"devDependencies": {
"@hocuspocus/provider": "3.4.4",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/express-ws": "3.0.6",
"@types/node": "*",
"@types/supertest": "7.2.1",
"@types/ws": "8.18.1",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
@@ -46,8 +38,7 @@
"tsc-alias": "1.9.1",
"typescript": "*",
"vitest": "4.1.10",
"vitest-mock-extended": "5.0.0",
"ws": "8.21.0"
"vitest-mock-extended": "5.0.0"
},
"packageManager": "yarn@1.22.22"
}
@@ -1,88 +0,0 @@
import { IncomingHttpHeaders } from 'http';
import axios from 'axios';
import { COLLABORATION_BACKEND_BASE_URL, Y_PROVIDER_API_KEY } from '@/env';
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
type Base64 = string;
interface Doc {
id: string;
title?: string;
content?: Base64;
creator: string;
is_favorite: boolean;
link_reach: 'restricted' | 'public' | 'authenticated';
link_role: 'reader' | 'editor';
nb_accesses_ancestors: number;
nb_accesses_direct: number;
created_at: string;
updated_at: string;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;
ai_proxy: boolean;
ai_transform: boolean;
ai_translate: boolean;
attachment_upload: boolean;
children_create: boolean;
children_list: boolean;
collaboration_auth: boolean;
destroy: boolean;
favorite: boolean;
invite_owner: boolean;
link_configuration: boolean;
media_auth: boolean;
move: boolean;
partial_update: boolean;
restore: boolean;
retrieve: boolean;
update: boolean;
versions_destroy: boolean;
versions_list: boolean;
versions_retrieve: boolean;
};
}
async function fetch<T>(
path: string,
requestHeaders: IncomingHttpHeaders,
): Promise<T> {
const response = await axios.get<T>(
`${COLLABORATION_BACKEND_BASE_URL}${path}`,
{
headers: {
cookie: requestHeaders['cookie'],
origin: requestHeaders['origin'],
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
},
},
);
if (response.status !== 200) {
throw new Error(`Failed to fetch ${path}: ${response.statusText}`);
}
return response.data;
}
export function fetchDocument(
{ name }: { name: string },
requestHeaders: IncomingHttpHeaders,
): Promise<Doc> {
return fetch<Doc>(`/api/v1.0/documents/${name}/`, requestHeaders);
}
export function fetchCurrentUser(
requestHeaders: IncomingHttpHeaders,
): Promise<User> {
return fetch<User>('/api/v1.0/users/me/', requestHeaders);
}
@@ -16,5 +16,3 @@ export const Y_PROVIDER_API_KEY = process.env.Y_PROVIDER_API_KEY_FILE
: 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 COLLABORATION_BACKEND_BASE_URL =
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
@@ -1,48 +0,0 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
type ResetConnectionsRequestQuery = {
room?: string;
};
export const collaborationResetConnectionsHandler = (
req: Request<object, object, object, ResetConnectionsRequestQuery>,
res: Response,
) => {
const room = req.query.room;
const userId = req.headers['x-user-id'];
logger('Resetting connections in room:', room, 'for user:', userId);
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
/**
* If no user ID is provided, close all connections in the room
*/
if (!userId) {
hocuspocusServer.hocuspocus.closeConnections(room);
} else {
/**
* Close connections for the user in the room
*/
hocuspocusServer.hocuspocus.documents.forEach((doc) => {
if (doc.name !== room) {
return;
}
doc.getConnections().forEach((connection) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (connection.context.userId === userId) {
connection.close();
}
});
});
}
res.status(200).json({ message: 'Connections reset' });
};
@@ -1,13 +0,0 @@
import { Request } from 'express';
import * as ws from 'ws';
import { hocuspocusServer } from '@/servers/hocuspocusServer';
export const collaborationWSHandler = (ws: ws.WebSocket, req: Request) => {
try {
hocuspocusServer.hocuspocus.handleConnection(ws, req);
} catch (error) {
console.error('Failed to handle WebSocket connection:', error);
ws.close();
}
};
@@ -1,48 +0,0 @@
import { Request, Response } from 'express';
import { hocuspocusServer } from '@/servers';
import { logger } from '@/utils';
type getDocumentConnectionInfoRequestQuery = {
room?: string;
sessionKey?: string;
};
export const getDocumentConnectionInfoHandler = (
req: Request<object, object, object, getDocumentConnectionInfoRequestQuery>,
res: Response,
) => {
const room = req.query.room;
const sessionKey = req.query.sessionKey;
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
if (!req.query.sessionKey) {
res.status(400).json({ error: 'Session key not provided' });
return;
}
logger('Getting document connection info for room:', room);
const roomInfo = hocuspocusServer.hocuspocus.documents.get(room);
if (!roomInfo) {
logger('Room not found:', room);
res.status(404).json({ error: 'Room not found' });
return;
}
const connections = roomInfo
.getConnections()
.filter((connection) => connection.readOnly === false);
res.status(200).json({
count: connections.length,
exists: connections.some(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(connection) => connection.context.sessionKey === sessionKey,
),
});
};
@@ -1,4 +1 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler';
export * from './convertHandler';
export * from './getDocumentConnectionInfoHandler';
@@ -1,6 +1,5 @@
import cors from 'cors';
import { NextFunction, Request, Response } from 'express';
import * as ws from 'ws';
import {
COLLABORATION_SERVER_ORIGIN,
@@ -8,8 +7,6 @@ import {
Y_PROVIDER_API_KEY,
} from '@/env';
import { logger } from './utils';
const VALID_API_KEYS = [COLLABORATION_SERVER_SECRET, Y_PROVIDER_API_KEY];
const allowedOrigins = COLLABORATION_SERVER_ORIGIN.split(',');
@@ -42,28 +39,3 @@ export const httpSecurity = (
next();
};
export const wsSecurity = (
ws: ws.WebSocket,
req: Request,
next: NextFunction,
): void => {
// Origin check
const origin = req.headers['origin'];
if (!origin || !allowedOrigins.includes(origin)) {
ws.close(4001, 'Origin not allowed');
logger('CORS policy violation: Invalid Origin', origin);
return;
}
const cookies = req.headers['cookie'];
if (!cookies) {
ws.close(4001, 'No cookies');
logger('CORS policy violation: No cookies');
logger('UA:', req.headers['user-agent']);
logger('URL:', req.url);
return;
}
next();
};
@@ -1,6 +1,3 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT: '/api/convert/',
COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/',
};
@@ -1,53 +1,24 @@
import * as Sentry from '@sentry/node';
import express from 'express';
import expressWebsockets from 'express-ws';
import { CONVERSION_FILE_MAX_SIZE } from '@/env';
import {
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertHandler,
getDocumentConnectionInfoHandler,
} from '@/handlers';
import { corsMiddleware, httpSecurity, wsSecurity } from '@/middlewares';
import { convertHandler } from '@/handlers';
import { corsMiddleware, httpSecurity } from '@/middlewares';
import { routes } from '@/routes';
import { logger } from '@/utils';
import '../services/sentry';
/**
* init the collaboration server.
* init the conversion server.
*
* @returns An object containing the Express app, Hocuspocus server, and HTTP server instance.
* @returns The Express app instance.
*/
export const initApp = () => {
const { app } = expressWebsockets(express());
const app = express();
app.use(corsMiddleware);
/**
* Route to handle WebSocket connections
*/
app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler);
/**
* Route to reset connections in a room:
* - If no user ID is provided, close all connections in the room
* - If a user ID is provided, close connections for the user in the room
*/
app.post(
routes.COLLABORATION_RESET_CONNECTIONS,
httpSecurity,
express.json(),
collaborationResetConnectionsHandler,
);
app.get(
routes.COLLABORATION_GET_CONNECTIONS,
httpSecurity,
getDocumentConnectionInfoHandler,
);
/**
* Route to convert Markdown or BlockNote blocks and Yjs content
*/
@@ -1,95 +0,0 @@
import { Server } from '@hocuspocus/server';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
import { fetchCurrentUser, fetchDocument } from '@/api/collaborationBackend';
import { logger } from '@/utils';
export const hocuspocusServer = new Server({
name: 'docs-collaboration',
timeout: 30000,
quiet: true,
async onConnect({
requestHeaders,
connectionConfig,
documentName,
requestParameters,
context,
request,
}) {
const roomParam = requestParameters.get('room');
if (documentName !== roomParam) {
logger(
'Invalid room name - Probable hacking attempt:',
documentName,
requestParameters.get('room'),
);
logger('UA:', request.headers['user-agent']);
logger('URL:', request.url);
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
if (!uuidValidate(documentName) || uuidVersion(documentName) !== 4) {
logger('Room name is not a valid uuid:', documentName);
return Promise.reject(new Error('Wrong room name: Unauthorized'));
}
let canEdit;
try {
const document = await fetchDocument(
{ name: documentName },
requestHeaders,
);
if (!document.abilities.retrieve) {
logger(
'onConnect: Unauthorized to retrieve this document',
documentName,
);
return Promise.reject(new Error('Wrong abilities:Unauthorized'));
}
canEdit = document.abilities.update;
} catch (error: unknown) {
if (error instanceof Error) {
logger('onConnect: backend error', error.message);
}
return Promise.reject(new Error('Backend error: Unauthorized'));
}
connectionConfig.readOnly = !canEdit;
const session = requestHeaders['cookie']
?.split('; ')
.find((cookie) => cookie.startsWith('docs_sessionid='));
if (session) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.sessionKey = session.split('=')[1];
}
/*
* Unauthenticated users can be allowed to connect
* so we flag only authenticated users
*/
try {
const user = await fetchCurrentUser(requestHeaders);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
context.userId = user.id;
} catch {
/* empty */
}
logger(
'Connection established on room:',
documentName,
'canEdit:',
canEdit,
);
return Promise.resolve();
},
});
@@ -1,2 +1 @@
export * from './appServer';
export * from './hocuspocusServer';
+18 -147
View File
@@ -2201,35 +2201,6 @@
resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-suggest-changes/-/prosemirror-suggest-changes-0.1.8.tgz#707d432376718d4618065b22aafbc55b9ce4ea5b"
integrity sha512-ewrJl4a8dTpPJNhqYySE2ZCjTRpXulWlUmFy3sbyJgPnGtN/zx7+8tbQ1OhHfMzZWfdmA8VjP9ecy+KO4HdOpA==
"@hocuspocus/common@^3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/common/-/common-3.4.4.tgz#a888fbd6dff2f0b8947c76b7841bddb89eb4d795"
integrity sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==
dependencies:
lib0 "^0.2.87"
"@hocuspocus/provider@3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-3.4.4.tgz#ab4ff0b55f9faf848ddbc5775956afee440a4e97"
integrity sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==
dependencies:
"@hocuspocus/common" "^3.4.4"
"@lifeomic/attempt" "^3.0.2"
lib0 "^0.2.87"
ws "^8.17.1"
"@hocuspocus/server@3.4.4":
version "3.4.4"
resolved "https://registry.yarnpkg.com/@hocuspocus/server/-/server-3.4.4.tgz#b44ad0aea9bdcc32d166e598278a4d5609cf03e9"
integrity sha512-UV+oaONAejOzeYgUygNcgsc8RdZvSokVvAxluZJIisLACpRO/VsseQ5lWKDRwLd7Fn6+rHWDH3hGuQ1fdX1Ycg==
dependencies:
"@hocuspocus/common" "^3.4.4"
async-lock "^1.3.1"
async-mutex "^0.5.0"
kleur "^4.1.4"
lib0 "^0.2.47"
ws "^8.5.0"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
@@ -2815,11 +2786,6 @@
resolved "https://registry.yarnpkg.com/@keyv/serialize/-/serialize-1.1.1.tgz#0c01dd3a3483882af7cf3878d4e71d505c81fc4a"
integrity sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==
"@lifeomic/attempt@^3.0.2":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.1.0.tgz#7fc703559177b81a008b9d263e3d9a001d11d08a"
integrity sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==
"@lottiefiles/dotlottie-react@^0.19.6":
version "0.19.10"
resolved "https://registry.yarnpkg.com/@lottiefiles/dotlottie-react/-/dotlottie-react-0.19.10.tgz#0f445a83eab1d83ec9b5aeab3daf4ce13c0f2adc"
@@ -6251,7 +6217,7 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
"@types/express-serve-static-core@^5.0.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz#74f47555b3d804b54cb7030e6f9aa0c7485cfc5b"
integrity sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==
@@ -6261,24 +6227,6 @@
"@types/range-parser" "*"
"@types/send" "*"
"@types/express-ws@3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/express-ws/-/express-ws-3.0.6.tgz#b38cee8f84db1c9aaf11a53964db07d58c90909c"
integrity sha512-6ZDt+tMEQgM4RC1sMX1fIO7kHQkfUDlWfxoPddXUeeDjmc+Yt/fCzqXfp8rFahNr5eIxdomrWphLEWDkB2q3UQ==
dependencies:
"@types/express" "*"
"@types/express-serve-static-core" "*"
"@types/ws" "*"
"@types/express@*":
version "5.0.3"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.3.tgz#6c4bc6acddc2e2a587142e1d8be0bce20757e956"
integrity sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^5.0.0"
"@types/serve-static" "*"
"@types/express@5.0.6":
version "5.0.6"
resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc"
@@ -6382,11 +6330,6 @@
resolved "https://registry.yarnpkg.com/@types/methods/-/methods-1.1.4.tgz#d3b7ac30ac47c91054ea951ce9eed07b1051e547"
integrity sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==
"@types/mime@^1":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.5.tgz#1ef302e01cf7d2b5a0fa526790c9123bf1d06690"
integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==
"@types/minimatch@^3.0.3":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
@@ -6469,23 +6412,6 @@
dependencies:
"@types/node" "*"
"@types/send@<1":
version "0.17.5"
resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.5.tgz#d991d4f2b16f2b1ef497131f00a9114290791e74"
integrity sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/serve-static@*":
version "1.15.9"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.9.tgz#f9b08ab7dd8bbb076f06f5f983b683654fe0a025"
integrity sha512-dOTIuqpWLyl3BBXU3maNQsS4A3zuuoYRNIvYSxxhebPfXg2mzWQEPne/nlJ37yOse6uGgR386uTpdsx4D0QZWA==
dependencies:
"@types/http-errors" "*"
"@types/node" "*"
"@types/send" "<1"
"@types/serve-static@^2":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-2.2.0.tgz#d4a447503ead0d1671132d1ab6bd58b805d8de6a"
@@ -6542,13 +6468,6 @@
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
"@types/ws@*", "@types/ws@8.18.1":
version "8.18.1"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
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"
@@ -7574,18 +7493,6 @@ async-function@^1.0.0:
resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b"
integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==
async-lock@^1.3.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
async-mutex@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.5.0.tgz#353c69a0b9e75250971a64ac203b0ebfddd75482"
integrity sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==
dependencies:
tslib "^2.4.0"
async@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce"
@@ -7618,16 +7525,6 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6"
integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==
axios@1.18.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.1.tgz#d63f9863bcd8938815c86f9e2abd380189d96dfe"
integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"
@@ -9531,13 +9428,6 @@ expect@^30.0.0:
jest-mock "30.2.0"
jest-util "30.2.0"
express-ws@5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-5.0.2.tgz#5b02d41b937d05199c6c266d7cc931c823bda8eb"
integrity sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==
dependencies:
ws "^7.4.6"
express@5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04"
@@ -9768,11 +9658,6 @@ flatted@^3.2.9, flatted@^3.3.3:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
fontkit@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.4.tgz#4765d664c68b49b5d6feb6bd1051ee49d8ec5ab0"
@@ -10387,7 +10272,7 @@ http-proxy-agent@^7.0.2:
agent-base "^7.1.0"
debug "^4.3.4"
https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
https-proxy-agent@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
@@ -11571,11 +11456,6 @@ kind-of@^6.0.2:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
kleur@^4.1.4:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
known-css-properties@^0.37.0:
version "0.37.0"
resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.37.0.tgz#10ebe49b9dbb6638860ff8a002fb65a053f4aec5"
@@ -11611,20 +11491,20 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
lib0@^0.2.109, lib0@^0.2.47, lib0@^0.2.85, lib0@^0.2.87:
version "0.2.114"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf"
integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==
dependencies:
isomorphic.js "^0.2.4"
lib0@^0.2.99:
lib0@^0.2.102, lib0@^0.2.99:
version "0.2.117"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716"
integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==
dependencies:
isomorphic.js "^0.2.4"
lib0@^0.2.109, lib0@^0.2.85:
version "0.2.114"
resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.114.tgz#0b0e55c3ffa8768fe3d9efca971059f465db4baf"
integrity sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==
dependencies:
isomorphic.js "^0.2.4"
lie@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
@@ -13034,11 +12914,6 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
pstree.remy@^1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a"
@@ -16046,16 +15921,11 @@ write-file-atomic@^5.0.1:
imurmurhash "^0.1.4"
signal-exit "^4.0.1"
ws@8.21.0, ws@^8.17.1, ws@^8.18.0, ws@^8.5.0:
ws@^8.18.0:
version "8.21.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
ws@^7.4.6:
version "7.5.11"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.11.tgz#9460daf1812bb81a423c5b9eac746941a86310fa"
integrity sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==
xml-js@^1.6.8:
version "1.6.11"
resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
@@ -16090,19 +15960,20 @@ y-prosemirror@^1.3.7:
dependencies:
lib0 "^0.2.109"
y-protocols@1.0.7:
y-protocols@1.0.7, y-protocols@^1.0.5, y-protocols@^1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.7.tgz#6631c492e75b78b3a61353a60067e6f8a4c38d5f"
integrity sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==
dependencies:
lib0 "^0.2.85"
y-protocols@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
y-websocket@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/y-websocket/-/y-websocket-3.0.0.tgz#e86bdb29cc0a53cb8d6e33ec8d24614a723832af"
integrity sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==
dependencies:
lib0 "^0.2.85"
lib0 "^0.2.102"
y-protocols "^1.0.5"
y18n@^5.0.5:
version "5.0.8"
+13
View File
@@ -0,0 +1,13 @@
# trixie for glibc >= 2.38 — uws prebuilt binaries reject bookworm's 2.36
FROM node:22-trixie AS yhub
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js ./
EXPOSE 3002
CMD ["node", "server.js"]
+738
View File
@@ -0,0 +1,738 @@
{
"name": "yhub-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yhub-server",
"dependencies": {
"@y/hub": "0.3.1"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@nodable/entities": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz",
"integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@redis/bloom": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz",
"integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/client": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz",
"integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==",
"license": "MIT",
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@node-rs/xxhash": "^1.1.0",
"@opentelemetry/api": ">=1 <2"
},
"peerDependenciesMeta": {
"@node-rs/xxhash": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@redis/json": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz",
"integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/search": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz",
"integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@redis/time-series": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz",
"integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==",
"license": "MIT",
"engines": {
"node": ">= 18.19.0"
},
"peerDependencies": {
"@redis/client": "^5.12.1"
}
},
"node_modules/@y-crdt/yn": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@y-crdt/yn/-/yn-0.1.4.tgz",
"integrity": "sha512-BrRpTE4tvONSx+hYXbpZERu7Cx3/xyHFNXKNnBd9/maTJNsCGaVwfkfH3PN9fx/IrIXxa3GTjX0t8zJam273BQ==",
"license": "ISC"
},
"node_modules/@y/hub": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@y/hub/-/hub-0.3.1.tgz",
"integrity": "sha512-gauvqZ2XwOi7c/Fu13xbOCV3uR+OCHH5QUMyIPXJfDvdTwILEo0hatQWaLK1NhfOZCcaxTPbpgeu2GdLGs7xhg==",
"license": "AGPL-3.0 OR PROPRIETARY",
"dependencies": {
"@y-crdt/yn": "^0.1.4",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": "^14.0.0-rc.24",
"lib0": "^1.0.0-rc.22",
"minio": "^8.0.6",
"pino": "^10.3.1",
"postgres": "^3.4.3",
"redis": "^5.10.0",
"uws": "github:uNetworking/uWebSockets.js#v20.57.0"
},
"bin": {
"yhub": "bin/yhub.js"
},
"engines": {
"node": ">=22.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/@y/protocols": {
"version": "1.0.6-rc.1",
"resolved": "https://registry.npmjs.org/@y/protocols/-/protocols-1.0.6-rc.1.tgz",
"integrity": "sha512-e/qs7hXcLk/SeNitxMXv2ymozyWFTULwbJEi7cAf/K/iXw9nGwGXHrR5TNluQ/bMwOX1cwuUT0hjEojkfH0gsA==",
"license": "MIT",
"dependencies": {
"lib0": "^1.0.0-rc.1"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"@y/y": "*"
}
},
"node_modules/@y/y": {
"version": "14.0.0-rc.24",
"resolved": "https://registry.npmjs.org/@y/y/-/y-14.0.0-rc.24.tgz",
"integrity": "sha512-E22nv/q6CWNodzU/yowxEp95TUVBTP5T63kAiJy2FpsuB5zxqfGhdW2222S4tFF9mo9UtBqg8ep7tPMJ9bZVOg==",
"license": "MIT",
"dependencies": {
"lib0": "^1.0.0-rc.21"
},
"engines": {
"node": ">=22.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/block-stream2": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/browser-or-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz",
"integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz",
"integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.0",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.1",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/is-unsafe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz",
"integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/lib0": {
"version": "1.0.0-rc.22",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.22.tgz",
"integrity": "sha512-KNefJloRQIsWncTF2tIcRqQXSQ7bDRYHwVSUhf1lY2P65Rej4WWFnen6L8L+odJQIo1ZNJGVVjK2WzqB9a+B/g==",
"license": "MIT",
"bin": {
"0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "src/bin/gentesthtml.js",
"0serve": "src/bin/0serve.js"
},
"engines": {
"node": ">=22"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minio": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
"integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.4",
"block-stream2": "^2.1.0",
"browser-or-node": "^2.1.1",
"buffer-crc32": "^1.0.0",
"eventemitter3": "^5.0.1",
"fast-xml-parser": "^5.3.4",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"query-string": "^7.1.3",
"stream-json": "^1.8.0",
"through2": "^4.0.2",
"xml2js": "^0.5.0 || ^0.6.2"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
"engines": {
"node": ">=12"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/process-warning": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redis": {
"version": "5.12.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz",
"integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.12.1",
"@redis/client": "5.12.1",
"@redis/json": "5.12.1",
"@redis/search": "5.12.1",
"@redis/time-series": "5.12.1"
},
"engines": {
"node": ">= 18.19.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strnum": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz",
"integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uws": {
"name": "uWebSockets.js",
"version": "20.57.0",
"resolved": "git+ssh://git@github.com/uNetworking/uWebSockets.js.git#fcfc622a4286909593b7f390056d89e0ca3b56b9",
"license": "Apache-2.0"
},
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "yhub-server",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@y/hub": "0.3.1"
},
"engines": {
"node": ">=22"
}
}
+98
View File
@@ -0,0 +1,98 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { createAuthPlugin, createYHub } from '@y/hub';
// mirror y-provider's env.ts secret-file support
const secret = (name, dflt) =>
process.env[`${name}_FILE`]
? readFileSync(process.env[`${name}_FILE`], 'utf8').trim()
: process.env[name] || dflt;
const PORT = Number(process.env.PORT || 3002);
const REDIS = process.env.REDIS;
const POSTGRES = process.env.POSTGRES;
const REDIS_PREFIX = process.env.REDIS_PREFIX || 'yhub';
const COLLABORATION_BACKEND_BASE_URL =
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
const allowedOrigins = (
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000'
).split(',');
const Y_PROVIDER_API_KEY = secret('Y_PROVIDER_API_KEY', 'yprovider-api-key');
const ORG = process.env.YHUB_ORG || 'docs';
const UUID4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const backendFetch = async (path, { cookie, origin }) => {
const res = await fetch(`${COLLABORATION_BACKEND_BASE_URL}${path}`, {
headers: {
cookie,
origin,
'X-Y-Provider-Key': Y_PROVIDER_API_KEY,
},
});
if (!res.ok) {
throw new Error(`Failed to fetch ${path}: ${res.status}`);
}
return res.json();
};
const auth = createAuthPlugin({
// uws req is only valid synchronously — read headers AND query before first await.
async readAuthInfo(req) {
const cookie = req.getHeader('cookie');
const origin = req.getHeader('origin');
const gcOff = req.getQuery('gc') === 'false';
if (gcOff) return null; // full-history connections: not for Docs users
if (!origin || !allowedOrigins.includes(origin)) return null; // was 4001 'Origin not allowed'
if (!cookie) return null; // was 4001 'No cookies'
try {
const user = await backendFetch('/api/v1.0/users/me/', {
cookie,
origin,
});
return { userid: String(user.id), cookie, origin }; // MUST be string (yhub server.js:667)
} catch {
// anonymous (public docs): stable per-session id — random ids would mint a new
// permanent attribution identity per reconnect
const anon = createHash('sha256')
.update(cookie)
.digest('base64url')
.slice(0, 16);
return { userid: `anon:${anon}`, cookie, origin };
}
},
async getAccessType(authInfo, { org, docid, branch }) {
if (org !== ORG || branch !== 'main' || !UUID4.test(docid)) {
return null;
}
try {
const doc = await backendFetch(
`/api/v1.0/documents/${docid}/`,
authInfo,
);
if (!doc.abilities?.retrieve) {
return null;
}
return doc.abilities.update ? 'rw' : 'r';
} catch {
return null;
}
},
});
await createYHub({
redis: {
url: REDIS,
prefix: REDIS_PREFIX,
taskDebounce: 10000,
minMessageLifetime: 60000,
},
postgres: POSTGRES,
persistence: [], // blobs live in yhub's postgres
server: { port: PORT, auth },
worker: { taskConcurrency: 5 },
// TODO(yhub): worker.events.docUpdate could push snapshots to Django and replace the
// client useSaveDoc PATCH flow — blocked upstream: the payload is a DocTable without
// room/org/docid (yhub src/index.js:90); needs an upstream change first.
});