From b4fc5ec2a36aad79383b7335f3d5e7e2ddfc0fb6 Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Wed, 27 May 2026 15:46:09 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20legacy=20conversion?= =?UTF-8?q?=20policy=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy Office formats need an explicit conversion policy before they can be opened for editing. Keep the mapping small and configuration-driven so the backend only converts formats intentionally forced by the WOPI client. --- src/backend/drive/settings.py | 9 --- src/backend/wopi/conversion/__init__.py | 1 + src/backend/wopi/conversion/policy.py | 42 +++++++++++ .../wopi/tests/conversion/test_policy.py | 74 +++++++++++++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 src/backend/wopi/conversion/__init__.py create mode 100644 src/backend/wopi/conversion/policy.py create mode 100644 src/backend/wopi/tests/conversion/test_policy.py diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 0f0159f6..4a11df59 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -1398,14 +1398,6 @@ class Base(Configuration): WOPI_LOCK_TIMEOUT = values.IntegerValue( 30 * 60, environ_name="WOPI_LOCK_TIMEOUT", environ_prefix=None ) -<<<<<<< HEAD -||||||| parent of f8297c68 (✨(backend) add OnlyOffice conversion backend) - WOPI_LEGACY_CONVERSION_TARGETS = { - "doc": "docx", - "xls": "xlsx", - "ppt": "pptx", - } -======= WOPI_LEGACY_CONVERSION_TARGETS = { "doc": "docx", "xls": "xlsx", @@ -1423,7 +1415,6 @@ class Base(Configuration): WOPI_ONLYOFFICE_CONVERT_DOWNLOAD_READ_TIMEOUT = values.IntegerValue( 30, environ_name="WOPI_ONLYOFFICE_CONVERT_DOWNLOAD_READ_TIMEOUT", environ_prefix=None ) ->>>>>>> f8297c68 (✨(backend) add OnlyOffice conversion backend) WOPI_DISABLE_CHAT = values.IntegerValue( 0, environ_name="WOPI_DISABLE_CHAT", environ_prefix=None ) diff --git a/src/backend/wopi/conversion/__init__.py b/src/backend/wopi/conversion/__init__.py new file mode 100644 index 00000000..701c0f9e --- /dev/null +++ b/src/backend/wopi/conversion/__init__.py @@ -0,0 +1 @@ +"""Server-to-server conversion of legacy Office files via WOPI providers.""" diff --git a/src/backend/wopi/conversion/policy.py b/src/backend/wopi/conversion/policy.py new file mode 100644 index 00000000..703572c9 --- /dev/null +++ b/src/backend/wopi/conversion/policy.py @@ -0,0 +1,42 @@ +"""Forced-conversion policy for WOPI legacy formats.""" + +from django.conf import settings + + +def _normalize(value): + return value.lower() if isinstance(value, str) else value + + +def target_extension_for(source_extension): + """Return the converted extension for a legacy source extension. + + Item.extension preserves filename case (REPORT.DOC -> "DOC"), so the + lookup must be case-insensitive. + """ + if not source_extension: + return None + + return settings.WOPI_LEGACY_CONVERSION_TARGETS.get(source_extension.lower()) + + +def is_forced_conversion(item, client_options): + """Return True when the active WOPI client forces a server-side conversion. + + The decision is product policy, not discovery-derived: a client is allowed + to advertise an "edit" action for a legacy format and still require the + Drive backend to convert it first. + """ + if not client_options: + return False + + extension = _normalize(item.extension) + forced_extensions = {_normalize(e) for e in client_options.get("ForceConvertExtensions") or []} + if extension and extension in forced_extensions: + return True + + mimetype = _normalize(item.mimetype) + forced_mimetypes = {_normalize(m) for m in client_options.get("ForceConvertMimetypes") or []} + if mimetype and mimetype in forced_mimetypes: + return True + + return False diff --git a/src/backend/wopi/tests/conversion/test_policy.py b/src/backend/wopi/tests/conversion/test_policy.py new file mode 100644 index 00000000..e26f06bd --- /dev/null +++ b/src/backend/wopi/tests/conversion/test_policy.py @@ -0,0 +1,74 @@ +"""Tests for the conversion policy helpers.""" + +from django.conf import settings + +from core import factories +from wopi.conversion.policy import is_forced_conversion, target_extension_for + + +def _item(filename="document.doc", mimetype="application/msword"): + """Build a minimal in-memory Item for policy tests.""" + return factories.ItemFactory.build(filename=filename, mimetype=mimetype) + + +def test_target_extension_for_known_legacy_formats(): + """Map known legacy Office extensions to their modern equivalents.""" + assert target_extension_for("doc") == "docx" + assert target_extension_for("xls") == "xlsx" + assert target_extension_for("ppt") == "pptx" + + +def test_target_extension_for_unknown_format_returns_none(): + """Return None for unsupported or empty source extensions.""" + assert target_extension_for("docx") is None + assert target_extension_for("") is None + assert target_extension_for(None) is None + + +def test_target_extension_for_is_case_insensitive(): + """Match the target extension regardless of source extension case.""" + assert target_extension_for("DOC") == "docx" + assert target_extension_for("Xls") == "xlsx" + + +def test_legacy_conversion_targets_only_lists_legacy_formats(): + """Restrict the conversion policy to legacy Office formats.""" + assert set(settings.WOPI_LEGACY_CONVERSION_TARGETS) == {"doc", "xls", "ppt"} + + +def test_is_forced_conversion_true_when_extension_listed(): + """Force conversion when the item extension is listed.""" + options = {"ForceConvertExtensions": ["doc", "xls", "ppt"]} + assert is_forced_conversion(_item(filename="document.doc"), options) is True + + +def test_is_forced_conversion_true_when_mimetype_listed(): + """Force conversion when the item mimetype is listed.""" + options = {"ForceConvertMimetypes": ["application/msword"]} + item = _item(filename="document.unknown", mimetype="application/msword") + assert is_forced_conversion(item, options) is True + + +def test_is_forced_conversion_false_when_neither_matches(): + """Skip conversion when neither extension nor mimetype matches.""" + options = { + "ForceConvertExtensions": ["doc"], + "ForceConvertMimetypes": ["application/msword"], + } + item = _item(filename="document.docx", mimetype="application/vnd.openxmlformats") + assert is_forced_conversion(item, options) is False + + +def test_is_forced_conversion_false_when_options_missing(): + """Skip conversion when client options are missing.""" + assert is_forced_conversion(_item(), {}) is False + assert is_forced_conversion(_item(), None) is False + + +def test_is_forced_conversion_extension_check_is_case_insensitive(): + """Match forced extensions regardless of case.""" + options = {"ForceConvertExtensions": ["doc"]} + assert is_forced_conversion(_item(filename="REPORT.DOC"), options) is True + + options = {"ForceConvertExtensions": ["DOC"]} + assert is_forced_conversion(_item(filename="document.doc"), options) is True