From a3dec0883fcb3d2e43cf0eedcd4d314e085613be Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 12 Aug 2026 11:57:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(yhub)=20add=20a=20restore=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will use the delete endpoint available in the yhub server with the soft delete feature in the backend application, but we also need a restore endpoint and this endpoint is not available in the yhub server. This commit adds a new custom endpoint implementing the restore action. --- src/yhub-server/README.md | 37 ++++++++++++++++++++++++++++-- src/yhub-server/server.js | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/yhub-server/README.md b/src/yhub-server/README.md index e9b15a4ef..1a7c9ee19 100644 --- a/src/yhub-server/README.md +++ b/src/yhub-server/README.md @@ -42,6 +42,10 @@ It is not a fork of yhub — it is a thin wrapper: - exposes `POST /collaboration/migrate/v1/{org}/{docid}`, which replays a document's **full** legacy version history out of the S3 media bucket (see "Full migration" below) — admin JWT only, like `reset-connections`, +- exposes `POST /collaboration/restore-ydoc/v1/{org}/{docid}`, which undoes the + deletion of a document — admin JWT only, like `reset-connections`. Deleting + one needs nothing custom, the built-in `DELETE .../ydoc/` does it (see + "Deletion" below); restoring has no built-in route, - notifies the Django backend on `POST /api/v1.0/documents/{id}/content-updated/` whenever the worker persists new content for a document, so that lists ordered by `updated_at` @@ -61,8 +65,9 @@ the websocket and the built-in document APIs (`ydoc`, `rollback`, `prune`, `changeset`, `activity`) are all guarded by the same cookie-based document authorization and are meant to be reachable by browsers, as is `/collaboration/jwks/v1`, which carries public keys and nothing else. The one -exception is `/collaboration/reset-connections/` and `/collaboration/migrate/`, which are -backend-internal and should not be routed through the public ingress. +exception is `/collaboration/reset-connections/`, `/collaboration/migrate/` and +`/collaboration/restore-ydoc/`, which are backend-internal and should not be +routed through the public ingress. ## Container image @@ -113,6 +118,34 @@ the counterpart of `make migrate` for the Django database. `make bootstrap` already includes it, so a fresh checkout needs nothing extra; an upgrade is `make migrate-yhub` and restart the service. +## Deletion + +The content of a document lives here, so deleting one in Docs has to be said +here too — otherwise the clients already connected keep editing it and the +content outlives the document. The backend does that from +`sync_service_deletions_in_cascade`, which walks the deleted subtree and tells +this server what became of each of its documents. + +Deleting is `DELETE /collaboration/ydoc/v1/{org}/{docid}`, built into yhub +0.6.0. It is a **soft** deletion: the deletion is recorded, the clients editing +the document are disconnected (websocket close code 4404) and every route +answers 404 for it (`{"code": "doc-deleted"}`, which a document that was never +written does not — that one answers an empty document), but its content is left +untouched. Deleting twice keeps the date of the first deletion. + +Restoring is the custom `POST /collaboration/restore-ydoc/v1/{org}/{docid}` +above: yhub 0.6.0 has no built-in route for it. The content was never touched, +so the document comes back with its whole history. Restoring one that is not +deleted answers 200 and changes nothing, which is what lets the backend restore +a subtree without asking what became of each document in it. + +Erasing the content for good is a third operation (`YHub.deleteDoc(room, { +hard: true })`), reachable from inside this process only — yhub deliberately +keeps it off the REST API. Nothing here calls it: Docs never erases a document +either, a soft-deleted one simply stops being restorable after +`TRASHBIN_CUTOFF_DAYS`. Note that a hard deletion is final for that room — the +docid can never be written again, and `restore-ydoc` answers 409 for it. + ## Soft migration (`SOFT_MIGRATION=true`) Documents were historically stored by the Django backend in the S3 media diff --git a/src/yhub-server/server.js b/src/yhub-server/server.js index 14d65f218..34560d20a 100644 --- a/src/yhub-server/server.js +++ b/src/yhub-server/server.js @@ -556,6 +556,53 @@ const api = [ }, }, }), + // POST /collaboration/restore-ydoc/v1/{org}/{docid} — undo the deletion of a + // document, putting back what `DELETE .../ydoc/` took away. + // + // Deleting has a built-in route, restoring does not: yhub 0.6.0 exposes + // `restoreDoc` to the process embedding it and nothing else. Backend-internal + // like reset-connections and migrate, gated to the admin token by the + // 'restore' purpose — a document leaves the trashbin because the backend + // says so, never because an editor asked. + createApiEndpoint('restore-ydoc', { + accessPurpose: 'restore', + post: { + 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' }); + } + if (req.branch !== 'main') { + // as in create-ydoc: the admin token is not fenced to main by + // getAccessType, and a deletion is recorded per branch + return jsonResponse(400, { error: 'Unknown branch' }); + } + // read the deletion before undoing it: `restoreDoc` throws a plain + // Error for a document whose content was erased, and that is a + // conflict to report as one — catching around the call would turn + // every failure alike, a database outage included, into the same answer + const tombstone = await req.yhub.persistence.retrieveTombstone(req.room); + if (tombstone == null) { + // not an error: the backend restores a whole subtree, of which only + // the part that was deleted with it has anything to put back + return jsonResponse(200, { + message: 'Document is not deleted', + restored: false, + }); + } + if (tombstone.hard || tombstone.purgedAt != null) { + return jsonResponse(409, { error: 'Document content was erased' }); + } + await req.yhub.restoreDoc(req.room); + return jsonResponse(200, { + message: 'Document restored', + restored: true, + }); + }, + }, + }), ]; // Django orders the document lists by `updated_at` and no edit goes through it