Skip to content

Commit 33968f4

Browse files
committed
✨(back) create item endpoint creating a WOPI session
In order to start a WOPI session, we have to fetch an endpoint on the item viewset returing the access token, the access token ttl and the wopi client launch url.
1 parent 0290e87 commit 33968f4

File tree

9 files changed

+304
-1
lines changed

9 files changed

+304
-1
lines changed

src/backend/core/api/viewsets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
from rest_framework.permissions import AllowAny
2323

2424
from core import enums, models
25+
from wopi.services import access as access_service
26+
from wopi.utils import get_wopi_client_config
2527

2628
from . import permissions, serializers, utils
2729
from .filters import ItemFilter, ListItemFilter
@@ -966,6 +968,31 @@ def media_auth(self, request, *args, **kwargs):
966968

967969
return drf.response.Response("authorized", headers=request.headers, status=200)
968970

971+
@drf.decorators.action(detail=True, methods=["get"], url_path="wopi")
972+
def wopi(self, request, *args, **kwargs):
973+
"""
974+
This view is used to generate an access token and access token ttl in order to start
975+
a WOPI session for the item and the current user.
976+
"""
977+
item = self.get_object()
978+
979+
if not (wopi_client := get_wopi_client_config(item)):
980+
raise drf.exceptions.ValidationError(
981+
{"detail": "This item does not suport WOPI integration."}
982+
)
983+
984+
service = access_service.AccessUserItemService()
985+
access_token, access_token_ttl = service.insert_new_access(item, request.user)
986+
987+
return drf.response.Response(
988+
{
989+
"access_token": access_token,
990+
"access_token_ttl": access_token_ttl,
991+
"launch_url": wopi_client["launch_url"],
992+
},
993+
status=drf.status.HTTP_200_OK,
994+
)
995+
969996

970997
class ItemAccessViewSet(
971998
ResourceAccessViewsetMixin,

src/backend/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ def get_abilities(self, user, ancestors_links=None):
681681
"media_auth": can_get,
682682
"update": can_update,
683683
"upload_ended": is_owner_or_admin,
684+
"wopi": can_get,
684685
}
685686

686687
def send_email(self, subject, emails, context=None, language=None):

src/backend/core/tests/items/test_api_items_retrieve.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def test_api_items_retrieve_anonymous_public_standalone():
4444
"tree": True,
4545
"update": item.link_role == "editor",
4646
"upload_ended": False,
47+
"wopi": True,
4748
},
4849
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
4950
"creator": str(item.creator.id),
@@ -104,6 +105,7 @@ def test_api_items_retrieve_anonymous_public_parent():
104105
"tree": True,
105106
"update": grand_parent.link_role == "editor",
106107
"upload_ended": False,
108+
"wopi": True,
107109
},
108110
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
109111
"creator": str(item.creator.id),
@@ -194,6 +196,7 @@ def test_api_items_retrieve_authenticated_unrelated_public_or_authenticated(reac
194196
"tree": True,
195197
"update": item.link_role == "editor",
196198
"upload_ended": False,
199+
"wopi": True,
197200
},
198201
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
199202
"creator": str(item.creator.id),
@@ -259,6 +262,7 @@ def test_api_items_retrieve_authenticated_public_or_authenticated_parent(reach):
259262
"tree": True,
260263
"update": grand_parent.link_role == "editor",
261264
"upload_ended": False,
265+
"wopi": True,
262266
},
263267
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
264268
"creator": str(item.creator.id),
@@ -438,6 +442,7 @@ def test_api_items_retrieve_authenticated_related_parent():
438442
"tree": True,
439443
"update": access.role != "reader",
440444
"upload_ended": access.role in ["administrator", "owner"],
445+
"wopi": True,
441446
},
442447
"creator": str(item.creator.id),
443448
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),

src/backend/core/tests/items/test_api_items_trashbin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def test_api_items_trashbin_format():
8484
"tree": True,
8585
"update": True,
8686
"upload_ended": True,
87+
"wopi": True,
8788
},
8889
"created_at": item.created_at.isoformat().replace("+00:00", "Z"),
8990
"creator": str(item.creator.id),
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Test for items API endpoint managing wopi init request."""
2+
3+
from django.contrib.auth.models import AnonymousUser
4+
from django.utils import timezone
5+
6+
import pytest
7+
from rest_framework.test import APIClient
8+
9+
from core import factories, models
10+
11+
pytestmark = pytest.mark.django_db
12+
13+
14+
@pytest.fixture
15+
def timestamp_now():
16+
"""Timestamp now in milliseconds."""
17+
return int(round(timezone.now().timestamp())) * 1000
18+
19+
20+
@pytest.fixture
21+
def valid_mimetype():
22+
"""Valid mimetype for testing."""
23+
return "application/vnd.oasis.opendocument.text"
24+
25+
26+
@pytest.fixture
27+
def valid_wopi_launch_url():
28+
"""Valid WOPI launch URL for testing."""
29+
return "https://vendorA.com/launch_url"
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def configure_wopi_settings(settings, valid_mimetype, valid_wopi_launch_url):
34+
settings.WOPI_CLIENTS = ["vendorA"]
35+
settings.WOPI_CLIENTS_CONFIGURATION = {
36+
"vendorA": {
37+
"launch_url": valid_wopi_launch_url,
38+
"mimetypes": [valid_mimetype],
39+
}
40+
}
41+
42+
43+
def test_api_items_wopi_not_existing_item():
44+
"""
45+
Anonymous user cannot generate wopi access token for non-existing item.
46+
"""
47+
48+
client = APIClient()
49+
response = client.get("/api/v1.0/items/00000000-0000-0000-0000-000000000000/wopi/")
50+
51+
assert response.status_code == 404
52+
53+
54+
def test_api_items_wopi_anonymous_user_item_public(
55+
timestamp_now, valid_mimetype, valid_wopi_launch_url
56+
):
57+
"""
58+
Anonymous user can generate wopi access token for public item.
59+
"""
60+
61+
item = factories.ItemFactory(
62+
link_reach=models.LinkReachChoices.PUBLIC,
63+
type=models.ItemTypeChoices.FILE,
64+
mimetype=valid_mimetype,
65+
)
66+
item.upload_state = models.ItemUploadStateChoices.UPLOADED
67+
item.save()
68+
69+
client = APIClient()
70+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
71+
72+
assert response.status_code == 200
73+
data = response.json()
74+
assert data["access_token"] is not None
75+
assert data["access_token_ttl"] > timestamp_now
76+
assert data["launch_url"] == valid_wopi_launch_url
77+
78+
79+
@pytest.mark.parametrize(
80+
"link_reach",
81+
[models.LinkReachChoices.AUTHENTICATED, models.LinkReachChoices.RESTRICTED],
82+
)
83+
def test_api_items_wopi_anonymous_user_item_not_public(link_reach):
84+
"""Anymous user can not access not public item."""
85+
item = factories.ItemFactory(link_reach=link_reach)
86+
87+
client = APIClient()
88+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
89+
90+
assert response.status_code == 401
91+
92+
93+
def test_api_items_wopi_anonymous_user_not_item_file():
94+
"""Anymous user can not access item that is not a file."""
95+
item = factories.ItemFactory(
96+
type=models.ItemTypeChoices.FOLDER, link_reach=models.LinkReachChoices.PUBLIC
97+
)
98+
99+
client = APIClient()
100+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
101+
102+
assert response.status_code == 400
103+
assert response.json() == {"detail": "This item does not suport WOPI integration."}
104+
105+
106+
def test_api_items_wopi_anonymous_item_file_mimetype_not_supported():
107+
"""Anymous user can not access item file with mimetype not supported."""
108+
item = factories.ItemFactory(
109+
type=models.ItemTypeChoices.FILE,
110+
mimetype="image/png",
111+
link_reach=models.LinkReachChoices.PUBLIC,
112+
)
113+
item.upload_state = models.ItemUploadStateChoices.UPLOADED
114+
item.save()
115+
116+
client = APIClient()
117+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
118+
119+
assert response.status_code == 400
120+
assert response.json() == {"detail": "This item does not suport WOPI integration."}
121+
122+
123+
def test_api_items_wopi_anonymous_user_item_not_uploaded():
124+
"""Anymous user can not access item file that is not uploaded."""
125+
item = factories.ItemFactory(
126+
type=models.ItemTypeChoices.FILE,
127+
mimetype="image/png",
128+
link_reach=models.LinkReachChoices.PUBLIC,
129+
)
130+
131+
client = APIClient()
132+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
133+
134+
assert response.status_code == 400
135+
assert response.json() == {"detail": "This item does not suport WOPI integration."}
136+
137+
138+
def test_api_items_wopi_authenticated_user_item_not_accessible():
139+
"""Authenticated user can not access item that is not accessible."""
140+
user = factories.UserFactory()
141+
item = factories.ItemFactory(link_reach=models.LinkReachChoices.RESTRICTED)
142+
143+
client = APIClient()
144+
client.force_login(user)
145+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
146+
147+
assert response.status_code == 403
148+
149+
150+
def test_api_items_wopi_authenticated_can_access_retricted_item(
151+
timestamp_now, valid_mimetype, valid_wopi_launch_url
152+
):
153+
"""Authenticated user can access item that is accessible."""
154+
user = factories.UserFactory()
155+
item = factories.ItemFactory(
156+
link_reach=models.LinkReachChoices.RESTRICTED,
157+
type=models.ItemTypeChoices.FILE,
158+
mimetype=valid_mimetype,
159+
)
160+
item.upload_state = models.ItemUploadStateChoices.UPLOADED
161+
item.save()
162+
factories.UserItemAccessFactory(user=user, item=item)
163+
164+
client = APIClient()
165+
client.force_login(user)
166+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
167+
168+
assert response.status_code == 200
169+
data = response.json()
170+
assert data["access_token"] is not None
171+
assert data["access_token_ttl"] > timestamp_now
172+
assert data["launch_url"] == valid_wopi_launch_url
173+
174+
175+
def test_api_items_wopi_authenticated_user_item_not_file():
176+
"""Authenticated user can not access item that is not a file."""
177+
user = factories.UserFactory()
178+
item = factories.ItemFactory(
179+
link_reach=models.LinkReachChoices.RESTRICTED,
180+
type=models.ItemTypeChoices.FOLDER,
181+
)
182+
factories.UserItemAccessFactory(user=user, item=item)
183+
184+
client = APIClient()
185+
client.force_login(user)
186+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
187+
188+
assert response.status_code == 400
189+
assert response.json() == {"detail": "This item does not suport WOPI integration."}
190+
191+
192+
def test_api_items_wopi_authenticated_user_item_mimetype_not_supported():
193+
"""Authenticated user can not access item that mimetype is not supported."""
194+
user = factories.UserFactory()
195+
item = factories.ItemFactory(
196+
link_reach=models.LinkReachChoices.RESTRICTED,
197+
type=models.ItemTypeChoices.FILE,
198+
mimetype="image/png",
199+
)
200+
item.upload_state = models.ItemUploadStateChoices.UPLOADED
201+
item.save()
202+
factories.UserItemAccessFactory(user=user, item=item)
203+
204+
client = APIClient()
205+
client.force_login(user)
206+
response = client.get(f"/api/v1.0/items/{item.id!s}/wopi/")
207+
208+
assert response.status_code == 400
209+
assert response.json() == {"detail": "This item does not suport WOPI integration."}

src/backend/core/tests/test_models_items.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def test_models_items_get_abilities_forbidden(
137137
"tree": False,
138138
"update": False,
139139
"upload_ended": False,
140+
"wopi": False,
140141
}
141142
nb_queries = 1 if is_authenticated else 0
142143
with django_assert_num_queries(nb_queries):
@@ -180,6 +181,7 @@ def test_models_items_get_abilities_reader(
180181
"tree": True,
181182
"update": False,
182183
"upload_ended": False,
184+
"wopi": True,
183185
}
184186
nb_queries = 1 if is_authenticated else 0
185187
with django_assert_num_queries(nb_queries):
@@ -223,6 +225,7 @@ def test_models_items_get_abilities_editor(
223225
"tree": True,
224226
"update": True,
225227
"upload_ended": False,
228+
"wopi": True,
226229
}
227230
nb_queries = 1 if is_authenticated else 0
228231
with django_assert_num_queries(nb_queries):
@@ -253,6 +256,7 @@ def test_models_items_get_abilities_owner(django_assert_num_queries):
253256
"tree": True,
254257
"update": True,
255258
"upload_ended": True,
259+
"wopi": True,
256260
}
257261
with django_assert_num_queries(1):
258262
assert item.get_abilities(user) == expected_abilities
@@ -283,6 +287,7 @@ def test_models_items_get_abilities_administrator(django_assert_num_queries):
283287
"tree": True,
284288
"update": True,
285289
"upload_ended": True,
290+
"wopi": True,
286291
}
287292
with django_assert_num_queries(1):
288293
assert item.get_abilities(user) == expected_abilities
@@ -312,6 +317,7 @@ def test_models_items_get_abilities_editor_user(django_assert_num_queries):
312317
"tree": True,
313318
"update": True,
314319
"upload_ended": False,
320+
"wopi": True,
315321
}
316322
with django_assert_num_queries(1):
317323
assert item.get_abilities(user) == expected_abilities
@@ -342,6 +348,7 @@ def test_models_items_get_abilities_reader_user(django_assert_num_queries):
342348
"tree": True,
343349
"update": access_from_link,
344350
"upload_ended": False,
351+
"wopi": True,
345352
}
346353
with django_assert_num_queries(1):
347354
assert item.get_abilities(user) == expected_abilities
@@ -375,6 +382,7 @@ def test_models_items_get_abilities_preset_role(django_assert_num_queries):
375382
"tree": True,
376383
"update": False,
377384
"upload_ended": False,
385+
"wopi": True,
378386
}
379387

380388

src/backend/wopi/services/access.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def generate_token():
7070
"""Generate a rancom access token"""
7171
return token_urlsafe()
7272

73-
def insert_new_access(self, item: Item, user: AbstractUser) -> str:
73+
def insert_new_access(self, item: Item, user: AbstractUser) -> tuple[str, int]:
7474
"""
7575
Insert a new access token for the user and item. Return an access_token and access_token_ttl
7676
access_token_ttl must be a timestamp in milliseconds

src/backend/wopi/utils/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,19 @@ def is_item_wopi_supported(item):
1919
if item.mimetype in client_config["mimetypes"]:
2020
return True
2121
return False
22+
23+
24+
def get_wopi_client_config(item):
25+
"""
26+
Get the WOPI client configuration for an item.
27+
"""
28+
if item.type != models.ItemTypeChoices.FILE:
29+
return False
30+
31+
if item.upload_state != models.ItemUploadStateChoices.UPLOADED:
32+
return False
33+
34+
for _, client_config in settings.WOPI_CLIENTS_CONFIGURATION.items():
35+
if item.mimetype in client_config["mimetypes"]:
36+
return client_config
37+
return None

0 commit comments

Comments
 (0)