Skip to content

Commit f1b398e

Browse files
lunikaAntoLC
authored andcommitted
✨(back) add endpoint checking media status
With the usage of a malware detection system, we need a way to know the file status. The front will use it to display a loader while the analyse is not ended.
1 parent d1f73f1 commit f1b398e

File tree

7 files changed

+311
-4
lines changed

7 files changed

+311
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ and this project adheres to
88

99
## [Unreleased]
1010

11-
## Added
11+
### Added
1212

13+
- ✨(back) add endpoint checking media status
1314
- ✨(backend) allow setting session cookie age via env var #977
1415
- ✨(backend) allow theme customnization using a configuration file #948
1516
- ✨ Add a custom callout block to the editor #892
@@ -18,7 +19,7 @@ and this project adheres to
1819
- 🩺(CI) add lint spell mistakes #954
1920
- 🛂(frontend) block edition to not connected users #945
2021

21-
## Changed
22+
### Changed
2223

2324
- 📝(frontend) Update documentation
2425
- ✅(frontend) Improve tests coverage

src/backend/core/api/viewsets.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,10 @@ def media_auth(self, request, *args, **kwargs):
12791279
# Check if the attachment is ready
12801280
s3_client = default_storage.connection.meta.client
12811281
bucket_name = default_storage.bucket_name
1282-
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
1282+
try:
1283+
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
1284+
except ClientError as err:
1285+
raise drf.exceptions.PermissionDenied() from err
12831286
metadata = head_resp.get("Metadata", {})
12841287
# In order to be compatible with existing upload without `status` metadata,
12851288
# we consider them as ready.
@@ -1294,6 +1297,50 @@ def media_auth(self, request, *args, **kwargs):
12941297

12951298
return drf.response.Response("authorized", headers=request.headers, status=200)
12961299

1300+
@drf.decorators.action(detail=True, methods=["get"], url_path="media-check")
1301+
def media_check(self, request, *args, **kwargs):
1302+
"""
1303+
Check if the media is ready to be served.
1304+
"""
1305+
document = self.get_object()
1306+
1307+
key = request.query_params.get("key")
1308+
if not key:
1309+
return drf.response.Response(
1310+
{"detail": "Missing 'key' query parameter"},
1311+
status=drf.status.HTTP_400_BAD_REQUEST,
1312+
)
1313+
1314+
if key not in document.attachments:
1315+
return drf.response.Response(
1316+
{"detail": "Attachment missing"},
1317+
status=drf.status.HTTP_404_NOT_FOUND,
1318+
)
1319+
1320+
# Check if the attachment is ready
1321+
s3_client = default_storage.connection.meta.client
1322+
bucket_name = default_storage.bucket_name
1323+
try:
1324+
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
1325+
except ClientError as err:
1326+
logger.error("Client Error fetching file %s metadata: %s", key, err)
1327+
return drf.response.Response(
1328+
{"detail": "Media not found"},
1329+
status=drf.status.HTTP_404_NOT_FOUND,
1330+
)
1331+
metadata = head_resp.get("Metadata", {})
1332+
1333+
body = {
1334+
"status": metadata.get("status", enums.DocumentAttachmentStatus.PROCESSING),
1335+
}
1336+
if metadata.get("status") == enums.DocumentAttachmentStatus.READY:
1337+
body = {
1338+
"status": enums.DocumentAttachmentStatus.READY,
1339+
"file": f"{settings.MEDIA_URL:s}{key:s}",
1340+
}
1341+
1342+
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
1343+
12971344
@drf.decorators.action(
12981345
detail=True,
12991346
methods=["post"],

src/backend/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,7 @@ def get_abilities(self, user, ancestors_links=None):
835835
"ai_transform": ai_access,
836836
"ai_translate": ai_access,
837837
"attachment_upload": can_update,
838+
"media_check": can_get,
838839
"children_list": can_get,
839840
"children_create": can_update and user.is_authenticated,
840841
"collaboration_auth": can_get,
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
"""Test the "media_check" endpoint."""
2+
3+
from io import BytesIO
4+
from uuid import uuid4
5+
6+
from django.core.files.storage import default_storage
7+
8+
import pytest
9+
from rest_framework.test import APIClient
10+
11+
from core import factories
12+
from core.enums import DocumentAttachmentStatus
13+
from core.tests.conftest import TEAM, USER, VIA
14+
15+
pytestmark = pytest.mark.django_db
16+
17+
18+
def test_api_documents_media_check_unknown_document():
19+
"""
20+
The "media_check" endpoint should return a 404 error if the document does not exist.
21+
"""
22+
client = APIClient()
23+
response = client.get(f"/api/v1.0/documents/{uuid4()!s}media-check/")
24+
assert response.status_code == 404
25+
26+
27+
def test_api_documents_media_check_missing_key():
28+
"""
29+
The "media_check" endpoint should return a 404 error if the key is missing.
30+
"""
31+
user = factories.UserFactory()
32+
33+
client = APIClient()
34+
client.force_login(user=user)
35+
36+
document = factories.DocumentFactory(users=[user])
37+
38+
response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/")
39+
assert response.status_code == 400
40+
assert response.json() == {"detail": "Missing 'key' query parameter"}
41+
42+
43+
def test_api_documents_media_check_key_parameter_not_related_to_document():
44+
"""
45+
The "media_check" endpoint should return a 404 error if the key is not related to the document.
46+
"""
47+
user = factories.UserFactory()
48+
49+
client = APIClient()
50+
client.force_login(user=user)
51+
52+
document = factories.DocumentFactory(users=[user])
53+
54+
response = client.get(
55+
f"/api/v1.0/documents/{document.id!s}/media-check/",
56+
{"key": f"{document.id!s}/attachments/unknown.jpg"},
57+
)
58+
assert response.status_code == 404
59+
assert response.json() == {"detail": "Attachment missing"}
60+
61+
62+
def test_api_documents_media_check_anonymous_public_document():
63+
"""
64+
The "media_check" endpoint should return a 200 status code if the document is public.
65+
"""
66+
document = factories.DocumentFactory(link_reach="public")
67+
68+
filename = f"{uuid4()!s}.jpg"
69+
key = f"{document.id!s}/attachments/{filename:s}"
70+
default_storage.connection.meta.client.put_object(
71+
Bucket=default_storage.bucket_name,
72+
Key=key,
73+
Body=BytesIO(b"my prose"),
74+
ContentType="text/plain",
75+
Metadata={"status": DocumentAttachmentStatus.PROCESSING},
76+
)
77+
document.attachments = [key]
78+
document.save(update_fields=["attachments"])
79+
80+
client = APIClient()
81+
82+
response = client.get(
83+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
84+
)
85+
assert response.status_code == 200
86+
assert response.json() == {"status": DocumentAttachmentStatus.PROCESSING}
87+
88+
89+
def test_api_documents_media_check_anonymous_public_document_ready():
90+
"""
91+
The "media_check" endpoint should return a 200 status code if the document is public.
92+
"""
93+
document = factories.DocumentFactory(link_reach="public")
94+
95+
filename = f"{uuid4()!s}.jpg"
96+
key = f"{document.id!s}/attachments/{filename:s}"
97+
default_storage.connection.meta.client.put_object(
98+
Bucket=default_storage.bucket_name,
99+
Key=key,
100+
Body=BytesIO(b"my prose"),
101+
ContentType="text/plain",
102+
Metadata={"status": DocumentAttachmentStatus.READY},
103+
)
104+
document.attachments = [key]
105+
document.save(update_fields=["attachments"])
106+
107+
client = APIClient()
108+
109+
response = client.get(
110+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
111+
)
112+
assert response.status_code == 200
113+
assert response.json() == {
114+
"status": DocumentAttachmentStatus.READY,
115+
"file": f"/media/{key:s}",
116+
}
117+
118+
119+
@pytest.mark.parametrize("link_reach", ["restricted", "authenticated"])
120+
def test_api_documents_media_check_anonymous_non_public_document(link_reach):
121+
"""
122+
The "media_check" endpoint should return a 403 error if the document is not public.
123+
"""
124+
document = factories.DocumentFactory(link_reach=link_reach)
125+
126+
client = APIClient()
127+
128+
response = client.get(f"/api/v1.0/documents/{document.id!s}/media-check/")
129+
assert response.status_code == 401
130+
131+
132+
def test_api_documents_media_check_connected_document():
133+
"""
134+
The "media_check" endpoint should return a 200 status code for a user connected
135+
checking for a document with link_reach authenticated.
136+
"""
137+
document = factories.DocumentFactory(link_reach="authenticated")
138+
139+
filename = f"{uuid4()!s}.jpg"
140+
key = f"{document.id!s}/attachments/{filename:s}"
141+
default_storage.connection.meta.client.put_object(
142+
Bucket=default_storage.bucket_name,
143+
Key=key,
144+
Body=BytesIO(b"my prose"),
145+
ContentType="text/plain",
146+
Metadata={"status": DocumentAttachmentStatus.READY},
147+
)
148+
document.attachments = [key]
149+
document.save(update_fields=["attachments"])
150+
151+
user = factories.UserFactory()
152+
client = APIClient()
153+
client.force_login(user=user)
154+
155+
response = client.get(
156+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
157+
)
158+
assert response.status_code == 200
159+
assert response.json() == {
160+
"status": DocumentAttachmentStatus.READY,
161+
"file": f"/media/{key:s}",
162+
}
163+
164+
165+
def test_api_documents_media_check_connected_document_media_not_related():
166+
"""
167+
The "media_check" endpoint should return a 404 error if the key is not related to the document.
168+
"""
169+
document = factories.DocumentFactory(link_reach="authenticated")
170+
171+
filename = f"{uuid4()!s}.jpg"
172+
key = f"{document.id!s}/attachments/{filename:s}"
173+
174+
user = factories.UserFactory()
175+
client = APIClient()
176+
client.force_login(user=user)
177+
178+
response = client.get(
179+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
180+
)
181+
assert response.status_code == 404
182+
assert response.json() == {"detail": "Attachment missing"}
183+
184+
185+
def test_api_documents_media_check_media_missing_on_storage():
186+
"""
187+
The "media_check" endpoint should return a 404 error if the media is missing on storage.
188+
"""
189+
document = factories.DocumentFactory(link_reach="authenticated")
190+
191+
filename = f"{uuid4()!s}.jpg"
192+
key = f"{document.id!s}/attachments/{filename:s}"
193+
194+
document.attachments = [key]
195+
document.save(update_fields=["attachments"])
196+
197+
user = factories.UserFactory()
198+
client = APIClient()
199+
client.force_login(user=user)
200+
201+
response = client.get(
202+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
203+
)
204+
assert response.status_code == 404
205+
assert response.json() == {"detail": "Media not found"}
206+
207+
208+
@pytest.mark.parametrize("via", VIA)
209+
def test_api_documents_media_check_restricted_document(via, mock_user_teams):
210+
"""
211+
The "media_check" endpoint should return a 200 status code if the document is restricted and
212+
the user has access to it.
213+
"""
214+
document = factories.DocumentFactory(link_reach="restricted")
215+
filename = f"{uuid4()!s}.jpg"
216+
key = f"{document.id!s}/attachments/{filename:s}"
217+
default_storage.connection.meta.client.put_object(
218+
Bucket=default_storage.bucket_name,
219+
Key=key,
220+
Body=BytesIO(b"my prose"),
221+
ContentType="text/plain",
222+
Metadata={"status": DocumentAttachmentStatus.READY},
223+
)
224+
document.attachments = [key]
225+
document.save(update_fields=["attachments"])
226+
227+
user = factories.UserFactory()
228+
client = APIClient()
229+
client.force_login(user=user)
230+
231+
if via == USER:
232+
factories.UserDocumentAccessFactory(document=document, user=user)
233+
elif via == TEAM:
234+
mock_user_teams.return_value = ["lasuite", "unknown"]
235+
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
236+
237+
response = client.get(
238+
f"/api/v1.0/documents/{document.id!s}/media-check/", {"key": key}
239+
)
240+
assert response.status_code == 200
241+
assert response.json() == {
242+
"status": DocumentAttachmentStatus.READY,
243+
"file": f"/media/{key:s}",
244+
}

src/backend/core/tests/documents/test_api_documents_retrieve.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
4848
"restricted": ["reader", "editor"],
4949
},
5050
"media_auth": True,
51+
"media_check": True,
5152
"move": False,
5253
"partial_update": document.link_role == "editor",
5354
"restore": False,
@@ -111,6 +112,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
111112
"link_configuration": False,
112113
"link_select_options": models.LinkReachChoices.get_select_options(links),
113114
"media_auth": True,
115+
"media_check": True,
114116
"move": False,
115117
"partial_update": grand_parent.link_role == "editor",
116118
"restore": False,
@@ -210,6 +212,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
210212
"restricted": ["reader", "editor"],
211213
},
212214
"media_auth": True,
215+
"media_check": True,
213216
"move": False,
214217
"partial_update": document.link_role == "editor",
215218
"restore": False,
@@ -279,8 +282,9 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
279282
"invite_owner": False,
280283
"link_configuration": False,
281284
"link_select_options": models.LinkReachChoices.get_select_options(links),
282-
"move": False,
283285
"media_auth": True,
286+
"media_check": True,
287+
"move": False,
284288
"partial_update": grand_parent.link_role == "editor",
285289
"restore": False,
286290
"retrieve": True,
@@ -460,6 +464,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
460464
"link_configuration": access.role in ["administrator", "owner"],
461465
"link_select_options": models.LinkReachChoices.get_select_options(links),
462466
"media_auth": True,
467+
"media_check": True,
463468
"move": access.role in ["administrator", "owner"],
464469
"partial_update": access.role != "reader",
465470
"restore": access.role == "owner",

src/backend/core/tests/documents/test_api_documents_trashbin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ def test_api_documents_trashbin_format():
9191
"restricted": ["reader", "editor"],
9292
},
9393
"media_auth": True,
94+
"media_check": True,
9495
"move": False, # Can't move a deleted document
9596
"partial_update": True,
9697
"restore": True,

0 commit comments

Comments
 (0)