mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-22 23:52:22 +02:00
♻️(collaboration) switch collaboration server from hocuspocus to yhub
Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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/
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
+5
-6
@@ -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) => {
|
||||
|
||||
+59
-108
@@ -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 }),
|
||||
}));
|
||||
|
||||
+1
-5
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
Generated
+738
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
});
|
||||
Reference in New Issue
Block a user