diff --git a/backend/app/services/onlyoffice_collaboration_service.py b/backend/app/services/onlyoffice_collaboration_service.py index 653574ea..b8386a07 100644 --- a/backend/app/services/onlyoffice_collaboration_service.py +++ b/backend/app/services/onlyoffice_collaboration_service.py @@ -2,8 +2,10 @@ from __future__ import annotations import hashlib import hmac +import io import json import uuid +import zipfile from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -57,12 +59,33 @@ async def _active_session( ).order_by(CollaborationSession.created_at.desc()) ) if session: - if session.status != "ACTIVE": - session.status = "ACTIVE" - session.closed_at = None + if session.status == "ERROR": + recovered = await _recover_forgotten_content(session.document_key, item.file_type) + if recovered is not None: + revision, _ = await collaboration_service.append_revision( + db, + item, + recovered, + source="SERVER_RECOVERY", + created_by=user_id, + change_summary="自动恢复在线文档服务器备份", + ) + session.base_revision_id = revision.id + # The failed key points to Document Server's recovery cache. A new + # generation must use a new key or every subsequent open falls back + # to the same unsaved backup again. + item.generation += 1 + session.status = "RECOVERED" if recovered is not None else "CLOSED" + session.closed_at = datetime.now(timezone.utc) await db.commit() - await db.refresh(session) - return session + session = None + else: + if session.status != "ACTIVE": + session.status = "ACTIVE" + session.closed_at = None + await db.commit() + await db.refresh(session) + return session if not item.current_revision_id: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容") session = CollaborationSession( @@ -339,6 +362,57 @@ async def _download_result(url: str) -> bytes: return bytes(content) +def _validate_recovered_content(content: bytes, file_type: str) -> None: + required_part = { + "word": "word/document.xml", + "cell": "xl/workbook.xml", + "slide": "ppt/presentation.xml", + }.get(file_type) + if not required_part or not zipfile.is_zipfile(io.BytesIO(content)): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份格式无效") + try: + with zipfile.ZipFile(io.BytesIO(content)) as package: + if required_part not in package.namelist() or package.testzip() is not None: + raise ValueError + except (zipfile.BadZipFile, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份已损坏") from exc + + +async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes | None: + """Download a Document Server backup left behind by a failed final save.""" + command = {"c": "getForgotten", "key": document_key} + token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256") + command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command" + try: + async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client: + response = await client.post( + command_url, + params={"shardkey": document_key}, + json={**command, "token": token}, + ) + if response.status_code != status.HTTP_200_OK: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") from exc + + error = payload.get("error") if isinstance(payload, dict) else None + if error == 1: + # Document Server no longer has a forgotten copy. Starting from the + # last confirmed CTMS revision is then the only recoverable state. + return None + if ( + error != 0 + or payload.get("key") != document_key + or not isinstance(payload.get("url"), str) + or not payload["url"] + ): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器返回的备份信息无效") + content = await _download_result(payload["url"]) + _validate_recovered_content(content, file_type) + return content + + async def _callback_user( db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession ) -> User | None: @@ -452,9 +526,13 @@ async def process_callback( session.status = "CLOSED" session.closed_at = datetime.now(timezone.utc) result = "UNCHANGED" - elif payload.status in {3, 7}: + elif payload.status == 3: session.status = "ERROR" result = "ERROR" + elif payload.status == 7: + # A force-save error does not close the live co-editing session. The + # final status 2 callback can still persist the document normally. + result = "ERROR" if payload.users or not ledger_access.is_ledger(item): session.active_users = json.dumps(payload.users, ensure_ascii=True) diff --git a/backend/app/templates/ledgers/contract-ledger.xlsx b/backend/app/templates/ledgers/contract-ledger.xlsx index 22a4c1f4..9b06edf7 100644 Binary files a/backend/app/templates/ledgers/contract-ledger.xlsx and b/backend/app/templates/ledgers/contract-ledger.xlsx differ diff --git a/backend/tests/test_collaboration_service.py b/backend/tests/test_collaboration_service.py index 2f27fbe7..d4b76cf1 100644 --- a/backend/tests/test_collaboration_service.py +++ b/backend/tests/test_collaboration_service.py @@ -316,6 +316,74 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m onlyoffice_collaboration_service._validate_result_url(value) +@pytest.mark.asyncio +async def test_forgotten_document_command_downloads_and_validates_server_backup(monkeypatch): + key = "ctms-collab-forgotten-key" + recovered = collaboration_service.blank_file_bytes("cell") + request = {} + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"error": 0, "key": key, "url": "http://onlyoffice/cache/forgotten.xlsx"} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, url, *, params, json): + request.update(url=url, params=params, body=json) + return FakeResponse() + + monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient()) + download = AsyncMock(return_value=recovered) + monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", download) + + result = await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell") + + assert result == recovered + assert request["url"] == "http://onlyoffice/command" + assert request["params"] == {"shardkey": key} + assert jwt.decode( + request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"] + ) == {"c": "getForgotten", "key": key} + download.assert_awaited_once_with("http://onlyoffice/cache/forgotten.xlsx") + + +@pytest.mark.asyncio +async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch): + key = "ctms-collab-damaged-key" + + class FakeResponse: + status_code = 200 + + @staticmethod + def json(): + return {"error": 0, "key": key, "url": "http://onlyoffice/cache/damaged.xlsx"} + + class FakeClient: + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + async def post(self, *_args, **_kwargs): + return FakeResponse() + + monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient()) + monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"broken")) + + with pytest.raises(HTTPException) as error: + await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell") + assert error.value.status_code == 502 + + @pytest.mark.asyncio async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path): user_id = uuid.uuid4() @@ -1016,6 +1084,31 @@ async def test_callback_error_status_is_recorded_and_acknowledged(): assert db.added[0].result == "ERROR" +@pytest.mark.asyncio +async def test_force_save_error_keeps_the_live_session_active(): + session = SimpleNamespace( + id=uuid.uuid4(), + file_id=uuid.uuid4(), + document_key="ctms-collab-force-save-error-key", + generation=1, + status="ACTIVE", + active_users=None, + last_callback_at=None, + ) + item = SimpleNamespace(id=session.file_id, generation=1) + db = _CallbackDb(session, item) + + result = await onlyoffice_collaboration_service.process_callback( + db, + session.id, + CollaborationCallbackPayload(key=session.document_key, status=7), + ) + + assert result == {"error": 0} + assert session.status == "ACTIVE" + assert db.added[0].result == "ERROR" + + @pytest.mark.asyncio async def test_force_save_updates_session_recovery_revision(monkeypatch): session = SimpleNamespace( diff --git a/backend/tests/test_ledgers.py b/backend/tests/test_ledgers.py index ad280099..3389c9a1 100644 --- a/backend/tests/test_ledgers.py +++ b/backend/tests/test_ledgers.py @@ -86,6 +86,41 @@ async def test_initialization_preserves_both_uploaded_templates_and_is_idempoten assert "ledgers" in Path(revision.file_uri).parts +@pytest.mark.asyncio +async def test_failed_ledger_session_recovers_server_backup_under_a_new_document_key(env, monkeypatch): + item = await env.db.get(CollaborationFile, env.initial[0].id) + first = await office.build_editor_config(env.db, item, env.admin) + failed = await env.db.scalar(select(CollaborationSession).where( + CollaborationSession.file_id == item.id, + CollaborationSession.generation == item.generation, + )) + failed.status = "ERROR" + await env.db.commit() + + current = await env.db.get(CollaborationRevision, item.current_revision_id) + recovered_buffer = io.BytesIO(Path(current.file_uri).read_bytes()) + with zipfile.ZipFile(recovered_buffer, "a") as package: + package.comment = b"document-server-recovery" + recovery = AsyncMock(return_value=recovered_buffer.getvalue()) + monkeypatch.setattr(office, "_recover_forgotten_content", recovery) + + reopened = await office.build_editor_config(env.db, item, env.admin) + await env.db.refresh(item) + await env.db.refresh(failed) + sessions = (await env.db.scalars(select(CollaborationSession).where( + CollaborationSession.file_id == item.id, + ).order_by(CollaborationSession.generation))).all() + recovered_revision = await env.db.get(CollaborationRevision, item.current_revision_id) + + assert first.config["document"]["key"] != reopened.config["document"]["key"] + assert item.generation == 2 + assert failed.status == "RECOVERED" + assert [session.generation for session in sessions] == [1, 2] + assert recovered_revision.source == "SERVER_RECOVERY" + assert recovered_revision.change_summary == "自动恢复在线文档服务器备份" + recovery.assert_awaited_once_with(failed.document_key, "cell") + + @pytest.mark.asyncio async def test_account_grants_are_independent_and_all_file_routes_require_access(env): await grant(env, env.editor, "EDITOR") diff --git a/docs/guides/onlyoffice-preview.md b/docs/guides/onlyoffice-preview.md index b5655938..7f31664c 100644 --- a/docs/guides/onlyoffice-preview.md +++ b/docs/guides/onlyoffice-preview.md @@ -28,31 +28,15 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret 轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。 -可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像增加 Noto Sans CJK/Noto Serif CJK;其他字体由上游镜像或部署方提供。 +可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只额外增加 Noto Sans CJK/Noto Serif CJK;其他字体仅继承自获准使用的上游镜像。 -## 导入宋体和 Times New Roman +## 字体许可边界 -将获准使用的字体放到 `onlyoffice/fonts/`,文件名为 `simsun.ttc`、`times.ttf`、`timesbd.ttf`、`timesi.ttf`、`timesbi.ttf`。宋体集合包含 SimSun(宋体)和 NSimSun(新宋体),Times New Roman 的四个文件分别提供常规、粗体、斜体和粗斜体。该目录的字体文件被 Git 忽略,必须在每台构建主机上单独提供;构建得到的镜像会包含这些字体。 +项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。 -从仓库根目录执行以下命令;如果运行的是独立开发栈,为每条 Compose 命令增加 `-f docker-compose.dev.yaml -p ctms_dev`: +部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。 -```bash -docker compose build onlyoffice -docker compose exec -T onlyoffice documentserver-prepare4shutdown.sh -docker compose up -d --no-deps onlyoffice -``` - -维护前先完成文档保存。上述关闭准备会等待在线文档保存,随后替换 ONLYOFFICE 容器;启动脚本自动重建字体索引。构建脚本仅在四个 Times New Roman 文件齐全时移除上游同名字体,避免编辑器继续选用旧版本。未提供字体的环境沿用原有字体。 - -健康检查通过后,重新打开在线文档,在字体列表中查找 `SimSun`(宋体)和 `Times New Roman`。可用以下命令核对字体来源: - -```bash -docker compose exec -T onlyoffice fc-match SimSun -docker compose exec -T onlyoffice fc-match 'Times New Roman' -docker compose exec -T onlyoffice curl -fsS http://127.0.0.1/healthcheck -``` - -补充说明:当前版本的生成器也会扫描 `onlyoffice_data` 卷中的 `/var/www/onlyoffice/Data/custom-fonts/`。放在该处的字体可跨容器重建保留,但同名字体仍可能被上游版本优先选中;替换 Times New Roman 时使用上述镜像构建流程。迁移部署时应单独携带字体来源文件。字体索引刷新方式参见 [ONLYOFFICE 官方字体安装说明](https://helpcenter.onlyoffice.com/docs/installation/docs-install-fonts-docker.aspx)。 +`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。 ## 配置边界 diff --git a/docs/onlyoffice-collaboration.md b/docs/onlyoffice-collaboration.md index 74d72657..6b74eedf 100644 --- a/docs/onlyoffice-collaboration.md +++ b/docs/onlyoffice-collaboration.md @@ -45,6 +45,8 @@ 5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。 6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。 +如果最终保存返回状态 3,后端将会话标记为保存失败。再次打开文件时,先通过 ONLYOFFICE `getForgotten` 命令取回服务器保留的备份副本,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。如果文档服务器已经没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。 + 内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。 ## 本地开发 diff --git a/frontend/src/components/collaboration/CollaborationHistoryDialog.vue b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue index 6eb27836..c7e7d475 100644 --- a/frontend/src/components/collaboration/CollaborationHistoryDialog.vue +++ b/frontend/src/components/collaboration/CollaborationHistoryDialog.vue @@ -227,6 +227,7 @@ function sourceLabel(source: string) { COPY: "复制创建", SHARE_FORCE_SAVE: "链接协作保存", SHARE_SESSION_CLOSE: "链接协作关闭", + SERVER_RECOVERY: "服务器备份恢复", } as Record)[source] || source; } diff --git a/onlyoffice/Dockerfile b/onlyoffice/Dockerfile index b61742d7..8b669cba 100644 --- a/onlyoffice/Dockerfile +++ b/onlyoffice/Dockerfile @@ -3,8 +3,8 @@ FROM ${ONLYOFFICE_IMAGE} USER root -# Use redistributable Noto CJK fonts for Chinese document preview. Proprietary -# Microsoft fonts must be supplied separately by an authorized deployment. +# Use redistributable Noto CJK fonts for Chinese document preview. Do not add +# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build. RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* @@ -16,20 +16,3 @@ RUN locale_root=/var/www/onlyoffice/documentserver/web-apps/apps \ && test -n "$matches" \ && sed -i 's/另存副本为/另存为/g' $matches \ && ! grep -RIl '另存副本为' "$locale_root"/*/main/locale/zh.json - -# Deployment-supplied fonts are kept out of Git, but included in local builds. -COPY fonts/ /usr/local/share/fonts/ctms/ - -# The upstream image contains an older Times New Roman family. Remove those -# duplicates only when the deployment supplies all four replacement faces. -RUN if [ -s /usr/local/share/fonts/ctms/times.ttf ] \ - && [ -s /usr/local/share/fonts/ctms/timesbd.ttf ] \ - && [ -s /usr/local/share/fonts/ctms/timesi.ttf ] \ - && [ -s /usr/local/share/fonts/ctms/timesbi.ttf ]; then \ - rm -f /usr/share/fonts/truetype/msttcorefonts/Times_New_Roman*.ttf \ - /usr/share/fonts/truetype/msttcorefonts/times.ttf \ - /usr/share/fonts/truetype/msttcorefonts/timesbd.ttf \ - /usr/share/fonts/truetype/msttcorefonts/timesi.ttf \ - /usr/share/fonts/truetype/msttcorefonts/timesbi.ttf; \ - fi \ - && fc-cache -f