diff --git a/CHANGELOG.md b/CHANGELOG.md index 210d00bc7..85426c9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ and this project adheres to `503` instead of denying access like a permission failure, so clients retry instead of giving up. The built-in endpoints can also answer JSON on `Accept: application/json` +- ✨(collaboration) add a get-ydoc endpoint on yhub - ✨(backend) call YHubService to seed initial document content - ✨(backend) reset the yhub connections of a document and its descendants when an access or the link configuration changes diff --git a/src/backend/core/services/yhub_services.py b/src/backend/core/services/yhub_services.py index ebab7cad3..c0f74c208 100644 --- a/src/backend/core/services/yhub_services.py +++ b/src/backend/core/services/yhub_services.py @@ -190,6 +190,19 @@ class YHubService: return response + def get_ydoc(self, document): + """ + Return the current Yjs state of a document, None when it has none. + + The raw update is what `create_ydoc` takes, so the state of a document + can be copied into another one. The built-in `ydoc` endpoint is not + used, it answers the lib0 encoding of an envelope rather than the + update itself. + """ + response = self.request("get", self.build_url("get-ydoc", document)) + + return response.content or None + def create_ydoc(self, document, update): """ Seed the initial Yjs state of a document. diff --git a/src/backend/core/tests/test_services_yhub_services.py b/src/backend/core/tests/test_services_yhub_services.py index e1b966a57..a9dfaf7c4 100644 --- a/src/backend/core/tests/test_services_yhub_services.py +++ b/src/backend/core/tests/test_services_yhub_services.py @@ -222,3 +222,29 @@ def test_reset_connections_of_a_single_user(mock_request): _args, kwargs = mock_request.call_args assert kwargs["headers"]["X-User-Id"] == str(user.pk) + + +@patch("requests.request") +def test_get_ydoc(mock_request): + """Should return the raw update the collaboration server holds.""" + mock_request.return_value.ok = True + mock_request.return_value.content = b"\x01\x02raw yjs update" + + update = YHubService().get_ydoc(DOCUMENT) + + assert update == b"\x01\x02raw yjs update" + args, _kwargs = mock_request.call_args + assert args == ( + "get", + f"http://yhub:3002/collaboration/get-ydoc/v1/docs/{DOCUMENT.id!s}", + ) + + +@patch("requests.request") +def test_get_ydoc_without_content(mock_request): + """A document the collaboration server holds no content for should return None.""" + mock_request.return_value.ok = True + # yhub answers 204 No Content, hence an empty body + mock_request.return_value.content = b"" + + assert YHubService().get_ydoc(DOCUMENT) is None diff --git a/src/yhub-server/server.js b/src/yhub-server/server.js index 029c9cb1c..f2d3e293a 100644 --- a/src/yhub-server/server.js +++ b/src/yhub-server/server.js @@ -340,6 +340,37 @@ const api = [ }, }, }), + // GET /collaboration/get-ydoc/v1/{org}/{docid} — the current state of a + // document as a RAW binary update (`Y.encodeStateAsUpdate` output), the read + // counterpart of create-ydoc: yhub's built-in `GET ydoc` answers a lib0-any + // encoded `{ doc, awareness }` envelope Django cannot decode. Answers 204 + // when the room holds no content. Default access purpose: guarded like the + // built-in ydoc routes (read access on the doc — the admin JWT, or a user + // session able to retrieve it). + createApiEndpoint('get-ydoc', { + get: { + handler: async (req) => { + if (req.org !== ORG) { + return jsonResponse(400, { error: 'Unknown org' }); + } + if (!UUID4.test(req.docid)) { + return jsonResponse(400, { error: 'Room name is invalid' }); + } + const { gcDoc } = await req.yhub.getDoc( + req.room, + { gc: true, nongc: false }, + { gcOnMerge: false }, + ); + // <= 3 bytes is yhub's "no effective content" convention (an empty + // update encodes to 2 bytes) — nothing to copy. `null` answers 204. + if (gcDoc == null || gcDoc.byteLength <= 3) { + return null; + } + // a Uint8Array is served as application/octet-stream, untouched + return gcDoc; + }, + }, + }), // POST /collaboration/create-ydoc/v1/{org}/{docid} — create a document's // initial Yjs state from a RAW binary update (`Y.encodeStateAsUpdate` / // pycrdt `get_update()` output) posted as application/octet-stream. Unlike