Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6afa9f0
✨(backend) add legacy conversion policy helpers
kernicPanel May 27, 2026
9fbd689
✨(backend) add OnlyOffice conversion backend
kernicPanel May 27, 2026
771d79b
✨(backend) build short-lived WOPI source URLs
kernicPanel May 27, 2026
c8cf73d
✨(backend) add converting upload state
kernicPanel May 27, 2026
b32ae76
✨(backend) add placeholder-backed conversion service
kernicPanel May 27, 2026
3c3b3b9
✨(backend) expose legacy conversion ability
kernicPanel May 27, 2026
39d014b
✨(backend) queue legacy file conversion
kernicPanel May 27, 2026
fc29a88
🐛(backend) normalize WOPI extension lookup
kernicPanel May 27, 2026
e29fe9e
✨(frontend) wire conversion API client
kernicPanel May 27, 2026
d6076ac
✨(frontend) add conversion modal
kernicPanel May 27, 2026
f3d884a
✨(frontend) render converting items as transient rows
kernicPanel May 27, 2026
8a1ed0d
♻️(frontend) generalize duplicating poller to transient items
kernicPanel May 28, 2026
c067374
✅(e2e) cover conversion explorer flow
kernicPanel May 27, 2026
be1eb1f
fixup! ✨(backend) add OnlyOffice conversion backend
kernicPanel Jun 1, 2026
eacabb1
fixup! ✨(backend) add legacy conversion policy helpers
kernicPanel Jun 1, 2026
50eb6f8
fixup! ✨(backend) build short-lived WOPI source URLs
kernicPanel Jun 1, 2026
265c653
fixup! ✨(backend) queue legacy file conversion
kernicPanel Jun 1, 2026
06c7ebf
fixup! ✨(backend) expose legacy conversion ability
kernicPanel Jun 1, 2026
22daed7
fixup! ✨(backend) build short-lived WOPI source URLs
kernicPanel Jun 1, 2026
338bef7
fixup! ✨(backend) add placeholder-backed conversion service
kernicPanel Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to
- ✨(backend) manage reconciliation requests for user accounts
- ✨(backend) add recursive folder export as ZIP archive
- ✨(frontend) add folder export action
- ✨(backend) background conversion of legacy Office files

### Changed

Expand Down
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ services:
environment:
TZ: "Europe/Berlin"
USE_UNAUTHORIZED_STORAGE: "true"
JWT_ENABLED: "false"
ports:
- "9981:80"
volumes:
Expand Down
7 changes: 7 additions & 0 deletions docker/onlyoffice/local-development.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,12 @@
"converter": {
"maxDownloadBytes": 209715200
}
},
"services": {
"CoAuthoring": {
"request-filtering-agent": {
"allowPrivateIPAddress": true
}
}
}
}
3 changes: 3 additions & 0 deletions env.d/development/common
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ WOPI_SRC_BASE_URL=http://app-dev:8000
# Disable rename for Collabora by default because it does not post messages when renaming a file.
# So the frontend cannot reflect renaming operations.
WOPI_COLLABORA_OPTIONS={"SupportsRename": False}
# Background conversion of legacy Office files (.doc, .xls, .ppt) is forced
# server-side via the OnlyOffice /converter endpoint.
WOPI_ONLYOFFICE_OPTIONS={"ForceConvertExtensions": ["doc", "xls", "ppt"], "ConvertServiceUrl": "http://onlyoffice/converter"}

# Indexer
# SEARCH_INDEXER_CLASS="core.services.search_indexers.SearchIndexer"
Expand Down
35 changes: 35 additions & 0 deletions src/backend/core/api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@
from core.storage import get_storage_compute_backend
from core.tasks.item import duplicate_file, process_item_purge, rename_file
from core.utils.analytics import posthog_capture
from wopi.conversion import exceptions as conversion_exceptions
from wopi.conversion.services import prepare_conversion
from wopi.services import access as access_service
from wopi.tasks.conversion import convert_file as convert_file_task
from wopi.utils import compute_wopi_launch_url, get_wopi_client_config

from . import permissions, serializers, utils
Expand Down Expand Up @@ -644,6 +647,38 @@
process_item_purge.delay(instance.id)
return drf.response.Response(status=status.HTTP_204_NO_CONTENT)

@drf.decorators.action(detail=True, methods=["post"], url_path="convert")
def convert(self, request, *args, **kwargs):
"""Queue a legacy Office file conversion.

Creates a placeholder Item in CONVERTING state in the destination folder
and dispatches the celery task that will attach the converted bytes.
"""
source = self.get_object()
try:
placeholder = prepare_conversion(source, request.user)
except conversion_exceptions.ConversionPermissionDenied as exc:
raise drf.exceptions.PermissionDenied() from exc
except (
conversion_exceptions.ConversionRejected,
conversion_exceptions.ConversionMisconfigured,
) as exc:
raise drf.exceptions.ValidationError({"detail": str(exc)}) from exc

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

try:
convert_file_task.delay(
source_item_id=str(source.id),
converted_item_id=str(placeholder.id),
user_id=str(request.user.id),
)
except Exception:
placeholder.soft_delete()
placeholder.delete()
raise

serializer = self.get_serializer(placeholder)
return drf.response.Response(serializer.data, status=status.HTTP_201_CREATED)

def list(self, request, *args, **kwargs):
"""List top level items with pagination and filtering."""
# Not calling filter_queryset. We do our own cooking.
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/migrations/0024_alter_item_upload_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-28 07:10

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0023_userreconciliationcsvimport_userreconciliation'),
]

operations = [
migrations.AlterField(
model_name='item',
name='upload_state',
field=models.CharField(blank=True, choices=[('pending', 'Pending'), ('duplicating', 'Duplicating'), ('converting', 'Converting'), ('analyzing', 'Analyzing'), ('suspicious', 'Suspicious'), ('file_too_large_to_analyze', 'File too large to analyze'), ('ready', 'Ready')], max_length=25, null=True),
),
]
15 changes: 14 additions & 1 deletion src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from timezone_field import TimeZoneField

from core.utils.item_title import manage_unique_title as manage_unique_title_utils
from wopi.conversion.policy import target_extension_for

logger = getLogger(__name__)

Expand Down Expand Up @@ -73,6 +74,7 @@ class ItemUploadStateChoices(models.TextChoices):

PENDING = "pending", _("Pending")
DUPLICATING = "duplicating", ("Duplicating")
CONVERTING = "converting", _("Converting")
ANALYZING = "analyzing", _("Analyzing")
SUSPICIOUS = "suspicious", _("Suspicious")
FILE_TOO_LARGE_TO_ANALYZE = (
Expand Down Expand Up @@ -1033,7 +1035,11 @@ def save(self, *args, **kwargs):
if (
self.created_at is None
and self.type == ItemTypeChoices.FILE
and self.upload_state != ItemUploadStateChoices.DUPLICATING
and self.upload_state
not in (
ItemUploadStateChoices.DUPLICATING,
ItemUploadStateChoices.CONVERTING,
)
):
self.upload_state = ItemUploadStateChoices.PENDING

Expand Down Expand Up @@ -1288,6 +1294,12 @@ def get_abilities(self, user):
and self.upload_state == ItemUploadStateChoices.READY
)
can_export = can_get and self.type == ItemTypeChoices.FOLDER
can_convert = (
can_update
and self.type == ItemTypeChoices.FILE
and self.upload_state == ItemUploadStateChoices.READY
and bool(target_extension_for(self.extension))
)

return {
"accesses_manage": can_manage,
Expand All @@ -1313,6 +1325,7 @@ def get_abilities(self, user):
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
"wopi": can_get,
"convert": can_convert,
}

def send_email(self, subject, emails, context=None, language=None):
Expand Down
116 changes: 116 additions & 0 deletions src/backend/core/tests/items/test_api_items_convert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Tests for the /items/<id>/convert/ endpoint."""

from unittest import mock

import pytest
from kombu.exceptions import KombuError
from rest_framework.test import APIClient

from core import factories, models

pytestmark = pytest.mark.django_db


def _build_user_and_item():
"""Build an EDITOR user with a legacy .doc item ready for conversion."""
user = factories.UserFactory()
parent = factories.ItemFactory(
users=[(user, models.RoleChoices.EDITOR)],
type=models.ItemTypeChoices.FOLDER,
)
item = factories.ItemFactory(
parent=parent,
users=[(user, models.RoleChoices.EDITOR)],
type=models.ItemTypeChoices.FILE,
filename="document.doc",
mimetype="application/msword",
update_upload_state=models.ItemUploadStateChoices.READY,
)
return user, item


@pytest.fixture(autouse=True)
def _wopi_settings(settings):
"""Configure the OnlyOffice WOPI client for the conversion tests."""
settings.WOPI_SRC_BASE_URL = "https://drive.example"
settings.WOPI_CLIENTS_CONFIGURATION = {
"onlyoffice": {
"options": {
"ForceConvertExtensions": ["doc"],
"ConvertServiceUrl": "http://onlyoffice/converter",
},
}
}


def test_convert_endpoint_creates_placeholder_and_queues_task():
"""Return the placeholder and enqueue the conversion task on POST /convert."""
user, item = _build_user_and_item()
client = APIClient()
client.force_login(user)

with mock.patch("core.api.viewsets.convert_file_task.delay") as delay_mock:
response = client.post(f"/api/v1.0/items/{item.id}/convert/")

assert response.status_code == 201
body = response.json()
placeholder = models.Item.objects.get(id=body["id"])
assert placeholder.upload_state == models.ItemUploadStateChoices.CONVERTING
assert placeholder.filename == "document.docx"
assert placeholder.parent().id == item.parent().id
delay_mock.assert_called_once_with(
source_item_id=str(item.id),
converted_item_id=str(placeholder.id),
user_id=str(user.id),
)


def test_convert_endpoint_cleans_up_placeholder_when_queueing_fails():
"""Remove the placeholder and let the broker error bubble up."""
user, item = _build_user_and_item()
client = APIClient(raise_request_exception=False)
client.force_login(user)

with mock.patch("core.api.viewsets.convert_file_task.delay", side_effect=KombuError):
response = client.post(f"/api/v1.0/items/{item.id}/convert/")

assert response.status_code == 500
assert not models.Item.objects.filter(
upload_state=models.ItemUploadStateChoices.CONVERTING
).exists()


def test_convert_endpoint_returns_403_when_user_cannot_update_item():
"""Return 403 and skip placeholder creation when the user cannot update."""
_, item = _build_user_and_item()
other_user = factories.UserFactory()
client = APIClient()
client.force_login(other_user)

with mock.patch("core.api.viewsets.convert_file_task.delay") as delay_mock:
response = client.post(f"/api/v1.0/items/{item.id}/convert/")

assert response.status_code == 403
assert not models.Item.objects.filter(
upload_state=models.ItemUploadStateChoices.CONVERTING
).exists()
delay_mock.assert_not_called()


def test_convert_endpoint_returns_403_for_unsupported_extension():
"""Reject conversion with 403 for non-legacy file extensions."""
user = factories.UserFactory()
item = factories.ItemFactory(
users=[(user, models.RoleChoices.EDITOR)],
type=models.ItemTypeChoices.FILE,
filename="image.png",
update_upload_state=models.ItemUploadStateChoices.READY,
)
client = APIClient()
client.force_login(user)

with mock.patch("core.api.viewsets.convert_file_task.delay") as delay_mock:
response = client.post(f"/api/v1.0/items/{item.id}/convert/")

assert response.status_code == 403
delay_mock.assert_not_called()
5 changes: 4 additions & 1 deletion src/backend/core/tests/items/test_api_items_retrieve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1516,7 +1516,10 @@ def test_api_items_retrieve_wopi_supported():
WOPI_CONFIGURATION_CACHE_KEY,
{
"mimetypes": {
"application/vnd.oasis.opendocument.text": "https://vendorA.com/launch_url",
"application/vnd.oasis.opendocument.text": {
"url": "https://vendorA.com/launch_url",
"client": "vendorA",
},
},
"extensions": {},
},
Expand Down
Loading
Loading