(backend) add a download action returning the media url

The media url is subject to change if the file is renamed. If someone is
using it as a permalink, it will lose the ability to download the file.
We created a new download action, always returning a redirection to the
media url.
This commit is contained in:
Manuel Raynaud
2026-02-24 15:30:51 +01:00
parent d9a05cb948
commit 4e5aefce27
6 changed files with 267 additions and 3 deletions
+1 -2
View File
@@ -13,6 +13,7 @@ and this project adheres to
- 👷(docker) add arm64 platform support for image builds
- ✨(global) add create file from template feature
- ✨(global) add FRONTEND_CSS_URL and FRONTEND_JS_URL settings
- ✨(backend) add a download action returning the media url
### Fixed
@@ -328,5 +329,3 @@ and this project adheres to
[v0.2.0]: https://github.com/suitenumerique/drive/releases/v0.2.0
[v0.1.1]: https://github.com/suitenumerique/drive/releases/v0.1.1
[v0.1.0]: https://github.com/suitenumerique/drive/releases/v0.1.0
## [v0.11.1] - 2026-01-13
+26 -1
View File
@@ -6,7 +6,7 @@ import logging
import os
import re
from io import BytesIO
from urllib.parse import unquote, urlparse
from urllib.parse import quote, unquote, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
@@ -1514,6 +1514,31 @@ class ItemViewSet(
)
return url_params, user_abilities, request.user.id, item
@drf.decorators.action(detail=True, methods=["get"], url_path="download")
def download(self, request, *args, **kwargs):
"""
Permalink endpoint for downloading an item's file.
Returns a redirect to the current media URL for the item, so this link
remains valid even after the item is renamed. Authentication is still
enforced by the existing media-auth mechanism on the redirected URL.
"""
item = self.get_object()
if item.type != models.ItemTypeChoices.FILE:
raise drf.exceptions.PermissionDenied()
if item.upload_state == models.ItemUploadStateChoices.PENDING:
raise drf.exceptions.PermissionDenied()
redirect_url = (
f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(item.file_key)}"
)
return drf.response.Response(
status=status.HTTP_302_FOUND,
headers={"Location": redirect_url},
)
@drf.decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
+1
View File
@@ -794,6 +794,7 @@ class Item(TreeModel, BaseModel):
"children_list": can_get,
"children_create": can_create_children,
"destroy": can_destroy,
"download": can_get,
"hard_delete": can_hard_delete,
"favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin,
@@ -0,0 +1,222 @@
"""
Test the item download permalink endpoint in drive's core app.
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_items_download_anonymous_public():
"""Anonymous users should be redirected when the item is public."""
item = factories.ItemFactory(
link_reach="public",
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.READY,
)
response = APIClient().get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert item.filename in response["Location"]
assert f"item/{item.pk!s}" in response["Location"]
@pytest.mark.parametrize("reach", ["authenticated", "restricted"])
def test_api_items_download_anonymous_authenticated_or_restricted(reach):
"""
Anonymous users should not be allowed to download items with link reach
set to authenticated or restricted.
"""
item = factories.ItemFactory(
link_reach=reach,
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.READY,
)
response = APIClient().get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 401
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_items_download_authenticated_public_or_authenticated(reach):
"""
Authenticated users without explicit access to a public or authenticated item
should be redirected to the media URL.
"""
item = factories.ItemFactory(
link_reach=reach,
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.READY,
)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert item.filename in response["Location"]
def test_api_items_download_authenticated_restricted():
"""
Authenticated users without explicit access to a restricted item
should not be allowed to download it.
"""
item = factories.ItemFactory(
link_reach="restricted",
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.READY,
)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 403
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize(
"upload_state",
[
models.ItemUploadStateChoices.READY,
models.ItemUploadStateChoices.ANALYZING,
models.ItemUploadStateChoices.FILE_TOO_LARGE_TO_ANALYZE,
],
)
def test_api_items_download_related(via, mock_user_teams, upload_state):
"""
Users with explicit access to an item should be redirected to the media URL.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(
type=models.ItemTypeChoices.FILE,
update_upload_state=upload_state,
)
if via == USER:
factories.UserItemAccessFactory(item=item, user=user)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamItemAccessFactory(item=item, team="lasuite")
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert item.filename in response["Location"]
assert f"item/{item.pk!s}" in response["Location"]
def test_api_items_download_redirect_url_stable_after_rename():
"""
The download permalink URL must remain valid after an item is renamed.
The redirect target should point to the current filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(
type=models.ItemTypeChoices.FILE,
filename="original_name.pdf",
update_upload_state=models.ItemUploadStateChoices.READY,
users=[(user, models.RoleChoices.EDITOR)],
)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert "original_name.pdf" in response["Location"]
# Simulate a rename by updating the filename directly
item.filename = "renamed_file.pdf"
item.save()
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert "renamed_file.pdf" in response["Location"]
assert "original_name.pdf" not in response["Location"]
def test_api_items_download_item_not_a_file():
"""Folders should not be downloadable via the permalink endpoint."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
factories.UserItemAccessFactory(item=item, user=user)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 403
def test_api_items_download_item_pending():
"""Pending items (upload not complete) should not be downloadable."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(
type=models.ItemTypeChoices.FILE,
upload_state=models.ItemUploadStateChoices.PENDING,
)
factories.UserItemAccessFactory(item=item, user=user)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 403
def test_api_items_download_suspicious_item_non_creator():
"""
Users who are not the creator of a suspicious item should not be able
to download it via the permalink endpoint.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.SUSPICIOUS,
users=[(user, models.RoleChoices.OWNER)],
)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 404
def test_api_items_download_suspicious_item_creator():
"""
The creator of a suspicious item should be redirected when downloading
via the permalink endpoint.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
item = factories.ItemFactory(
creator=user,
type=models.ItemTypeChoices.FILE,
update_upload_state=models.ItemUploadStateChoices.SUSPICIOUS,
users=[(user, models.RoleChoices.OWNER)],
)
response = client.get(f"/api/v1.0/items/{item.pk}/download/")
assert response.status_code == 302
assert item.filename in response["Location"]
@@ -252,6 +252,7 @@ def test_models_items_get_abilities_forbidden(
"favorite": False,
"invite_owner": False,
"media_auth": False,
"download": False,
"move": False,
"link_configuration": False,
"link_select_options": {},
@@ -301,6 +302,7 @@ def test_models_items_get_abilities_reader(
"link_configuration": False,
"link_select_options": {},
"media_auth": True,
"download": True,
"move": False,
"partial_update": False,
"restore": False,
@@ -352,6 +354,7 @@ def test_models_items_get_abilities_editor(
"link_configuration": False,
"link_select_options": {},
"media_auth": True,
"download": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -396,6 +399,7 @@ def test_models_items_not_root_get_abilities_owner(django_assert_num_queries):
"restricted": None,
},
"media_auth": True,
"download": True,
"move": True,
"partial_update": True,
"restore": True,
@@ -422,6 +426,7 @@ def test_models_items_not_root_get_abilities_owner(django_assert_num_queries):
"link_configuration": False,
"link_select_options": {},
"media_auth": False,
"download": False,
"move": False,
"partial_update": False,
"restore": True,
@@ -456,6 +461,7 @@ def test_models_items_not_root_get_abilities_administrator(django_assert_num_que
"restricted": None,
},
"media_auth": True,
"download": True,
"move": True,
"partial_update": True,
"restore": False,
@@ -499,6 +505,7 @@ def test_models_items_not_root_get_abilities_editor_user(django_assert_num_queri
"link_configuration": False,
"link_select_options": link_select_options,
"media_auth": True,
"download": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -547,6 +554,7 @@ def test_models_items_not_root_get_abilities_reader_user(django_assert_num_queri
"restricted": None,
},
"media_auth": True,
"download": True,
"move": False,
"partial_update": access_from_link,
"restore": False,
@@ -10,6 +10,8 @@ from core import factories, models
pytestmark = pytest.mark.django_db
# # pylint: disable=duplicate-code
def test_models_sub_item_abilities_downgraded():
"""
@@ -58,6 +60,7 @@ def test_models_sub_item_abilities_downgraded():
"restricted": None,
},
"media_auth": True,
"download": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -90,6 +93,7 @@ def test_models_sub_item_abilities_downgraded():
"restricted": None,
},
"media_auth": True,
"download": True,
"move": False,
"partial_update": False,
"restore": False,
@@ -121,6 +125,7 @@ def test_models_items_root_get_abilities_owner(django_assert_num_queries):
"link_configuration": True,
"link_select_options": link_select_options,
"media_auth": True,
"download": True,
"move": True,
"partial_update": True,
"restore": True,
@@ -147,6 +152,7 @@ def test_models_items_root_get_abilities_owner(django_assert_num_queries):
"link_configuration": False,
"link_select_options": {},
"media_auth": False,
"download": False,
"move": False,
"partial_update": False,
"restore": True,
@@ -178,6 +184,7 @@ def test_models_items_root_get_abilities_administrator(django_assert_num_queries
"link_configuration": True,
"link_select_options": link_select_options,
"media_auth": True,
"download": True,
"move": True,
"partial_update": True,
"restore": False,
@@ -218,6 +225,7 @@ def test_models_items_root_get_abilities_editor_user(django_assert_num_queries):
"link_configuration": False,
"link_select_options": link_select_options,
"media_auth": True,
"download": True,
"move": False,
"partial_update": True,
"restore": False,
@@ -259,6 +267,7 @@ def test_models_items_root_get_abilities_reader_user(django_assert_num_queries):
"link_configuration": False,
"link_select_options": link_select_options,
"media_auth": True,
"download": True,
"move": False,
"partial_update": access_from_link,
"restore": False,