From a997fa826ffe97bfc469ccd0a0d70710cd476044 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Wed, 27 May 2026 15:46:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20build=20short-lived=20WOPI?= =?UTF-8?q?=20source=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnlyOffice must fetch the source bytes from Drive during server-side conversion. Use short-lived WOPI access URLs so conversion does not depend on direct object storage reachability. --- docker/onlyoffice/local-development.json | 7 ++ src/backend/drive/settings.py | 3 + src/backend/wopi/conversion/source_url.py | 22 +++++++ src/backend/wopi/services/access.py | 19 +++--- .../wopi/tests/conversion/test_source_url.py | 64 +++++++++++++++++++ 5 files changed, 107 insertions(+), 8 deletions(-) create mode 100644 src/backend/wopi/conversion/source_url.py create mode 100644 src/backend/wopi/tests/conversion/test_source_url.py diff --git a/docker/onlyoffice/local-development.json b/docker/onlyoffice/local-development.json index cedbb6ea..b54475fe 100644 --- a/docker/onlyoffice/local-development.json +++ b/docker/onlyoffice/local-development.json @@ -18,5 +18,12 @@ "converter": { "maxDownloadBytes": 209715200 } + }, + "services": { + "CoAuthoring": { + "request-filtering-agent": { + "allowPrivateIPAddress": true + } + } } } diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 4a11df59..ba4c1d68 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -1395,6 +1395,9 @@ class Base(Configuration): WOPI_ACCESS_TOKEN_TIMEOUT = values.IntegerValue( 60 * 60 * 10, environ_name="WOPI_ACCESS_TOKEN_TIMEOUT", environ_prefix=None ) + WOPI_CONVERSION_SOURCE_TOKEN_TIMEOUT = values.IntegerValue( + 120, environ_name="WOPI_CONVERSION_SOURCE_TOKEN_TIMEOUT", environ_prefix=None + ) WOPI_LOCK_TIMEOUT = values.IntegerValue( 30 * 60, environ_name="WOPI_LOCK_TIMEOUT", environ_prefix=None ) diff --git a/src/backend/wopi/conversion/source_url.py b/src/backend/wopi/conversion/source_url.py new file mode 100644 index 00000000..47c3813a --- /dev/null +++ b/src/backend/wopi/conversion/source_url.py @@ -0,0 +1,22 @@ +"""Build the short-lived WOPI URL OnlyOffice uses to fetch the source bytes.""" + +from django.conf import settings + +from wopi.conversion.exceptions import ConversionMisconfigured +from wopi.services.access import AccessUserItemService + + +def build_source_url(item, user): + """Return a short-lived WOPI GetFile URL pointing at the item for the user.""" + base_url = settings.WOPI_SRC_BASE_URL + + if not base_url: + raise ConversionMisconfigured("Missing WOPI_SRC_BASE_URL for conversion source URL") + + access_token, _ttl_ms = AccessUserItemService().insert_new_access( + item, user, ttl=settings.WOPI_CONVERSION_SOURCE_TOKEN_TIMEOUT + ) + return ( + f"{base_url.rstrip('/')}/api/{settings.API_VERSION}" + f"/wopi/files/{item.id}/contents/?access_token={access_token}" + ) diff --git a/src/backend/wopi/services/access.py b/src/backend/wopi/services/access.py index 857e85c0..537ba008 100644 --- a/src/backend/wopi/services/access.py +++ b/src/backend/wopi/services/access.py @@ -68,22 +68,25 @@ class AccessUserItemService: """Generate a random access token""" return token_urlsafe() - def insert_new_access(self, item: Item, user: AbstractUser) -> tuple[str, int]: + def insert_new_access( + self, item: Item, user: AbstractUser, ttl: int | None = None + ) -> tuple[str, int]: """ Insert a new access token for the user and item. Return an access_token and access_token_ttl - access_token_ttl must be a timestamp in milliseconds + access_token_ttl must be a timestamp in milliseconds. + + ttl overrides the default WOPI_ACCESS_TOKEN_TIMEOUT lifetime. Pass a short + ttl for one-shot uses (server-to-server conversion source download, etc.). """ abilities = item.get_abilities(user) if not abilities["retrieve"]: raise AccessUserItemNotAllowed() + + effective_ttl = ttl if ttl is not None else settings.WOPI_ACCESS_TOKEN_TIMEOUT token = self.generate_token() access_user_item = AccessUserItem(item=item, user=user) - token_eol = timezone.now() + timedelta(seconds=settings.WOPI_ACCESS_TOKEN_TIMEOUT) - cache.set( - token, - access_user_item.to_dict(), - timeout=settings.WOPI_ACCESS_TOKEN_TIMEOUT, - ) + token_eol = timezone.now() + timedelta(seconds=effective_ttl) + cache.set(token, access_user_item.to_dict(), timeout=effective_ttl) return token, int(round(token_eol.timestamp())) * 1000 def get_access_user_item(self, token: str) -> AccessUserItem: diff --git a/src/backend/wopi/tests/conversion/test_source_url.py b/src/backend/wopi/tests/conversion/test_source_url.py new file mode 100644 index 00000000..06dd15ce --- /dev/null +++ b/src/backend/wopi/tests/conversion/test_source_url.py @@ -0,0 +1,64 @@ +"""Tests for the WOPI source-URL builder used by the conversion service.""" + +from unittest import mock + +import pytest + +from core import factories +from wopi.conversion import exceptions +from wopi.conversion.source_url import build_source_url + + +@pytest.fixture +def _access_service(): + """Mock the AccessUserItemService used by build_source_url.""" + service = mock.Mock() + service.return_value.insert_new_access.return_value = ("tok-abc", 1_700_000_000_000) + with mock.patch("wopi.conversion.source_url.AccessUserItemService", service): + yield service + + +def test_build_source_url_uses_configured_wopi_base_url(settings, _access_service): + """Start the built URL with WOPI_SRC_BASE_URL and embed the access token.""" + settings.WOPI_SRC_BASE_URL = "https://drive.example" + item = factories.ItemFactory.build() + user = factories.UserFactory.build() + + url = build_source_url(item, user) + + assert url == ( + f"https://drive.example/api/v1.0/wopi/files/{item.id}/contents/?access_token=tok-abc" + ) + + +def test_build_source_url_strips_trailing_slash_on_base_url(settings, _access_service): + """Strip a trailing slash on the base URL to avoid a double slash.""" + settings.WOPI_SRC_BASE_URL = "https://drive.example/" + item = factories.ItemFactory.build() + user = factories.UserFactory.build() + + url = build_source_url(item, user) + + assert "//api" not in url + + +def test_build_source_url_raises_when_base_url_is_missing(settings, _access_service): + """Raise when WOPI_SRC_BASE_URL is missing.""" + settings.WOPI_SRC_BASE_URL = None + item = factories.ItemFactory.build() + user = factories.UserFactory.build() + + with pytest.raises(exceptions.ConversionMisconfigured, match="Missing WOPI_SRC_BASE_URL"): + build_source_url(item, user) + + +def test_build_source_url_delegates_to_access_user_item_service(settings, _access_service): + """Issue a short-lived WOPI access token via the existing service.""" + settings.WOPI_SRC_BASE_URL = "https://drive.example" + settings.WOPI_CONVERSION_SOURCE_TOKEN_TIMEOUT = 90 + item = factories.ItemFactory.build() + user = factories.UserFactory.build() + + build_source_url(item, user) + + _access_service.return_value.insert_new_access.assert_called_once_with(item, user, ttl=90)