From 4053c924c2ff6df3954148743e31320c54958bf2 Mon Sep 17 00:00:00 2001 From: Maarten Draijer Date: Wed, 8 Jul 2026 15:07:08 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20upload-only=20("file?= =?UTF-8?q?=20request")=20link=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC / draft for #770. Adds a link_upload_only flag on Item: when set on a link with PUBLIC or AUTHENTICATED reach, the link grants create-only access. An (anonymous) recipient can add files but cannot list, download, or edit the folder's existing contents, unlike the current Public + Editor path which requires authentication and exposes everything already there. get_abilities: an upload-only link no longer raises the user's role (so read/list/download stay off); children_create is granted even anonymously; retrieve stays on so the recipient can see the target folder to upload into. Exposed read-only on ItemSerializer and writable via LinkItemSerializer. Backend only for now; the frontend drop-zone view is a fast-follow. Opening as a draft to align on the design (this Drive-local flag vs. a role in django-lasuite) before polishing. --- CHANGELOG.md | 1 + src/backend/core/api/serializers.py | 6 +++ .../migrations/0025_item_link_upload_only.py | 15 +++++++ src/backend/core/models.py | 22 ++++++++-- src/backend/core/tests/test_models_items.py | 40 +++++++++++++++++++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/backend/core/migrations/0025_item_link_upload_only.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bf354c789..5efecfe33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(backend) add upload-only ("file request") link mode (#770) - ✨(backend) allow converting a file while it is being analyzed - ✨(frontend) add file type, contact and modification date topbar filters - ✨(frontend) add location, file type, contact and date search filters diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d24544aeb..812ba251f 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -248,6 +248,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "link_upload_only", "nb_accesses", "numchild", "numchild_folder", @@ -282,6 +283,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "link_upload_only", "nb_accesses", "path", "updated_at", @@ -478,6 +480,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "link_upload_only", "nb_accesses", "numchild", "numchild_folder", @@ -583,6 +586,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "link_upload_only", "nb_accesses", "numchild", "numchild_folder", @@ -614,6 +618,7 @@ class Meta: "is_favorite", "link_role", "link_reach", + "link_upload_only", "nb_accesses", "path", "updated_at", @@ -746,6 +751,7 @@ class Meta: fields = [ "link_role", "link_reach", + "link_upload_only", ] def validate(self, attrs): diff --git a/src/backend/core/migrations/0025_item_link_upload_only.py b/src/backend/core/migrations/0025_item_link_upload_only.py new file mode 100644 index 000000000..b86cd364b --- /dev/null +++ b/src/backend/core/migrations/0025_item_link_upload_only.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0024_alter_item_upload_state"), + ] + + operations = [ + migrations.AddField( + model_name="item", + name="link_upload_only", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index ad065b2c9..546ef86f9 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -970,6 +970,11 @@ class Item(TreeModel, BaseModel): link_role = models.CharField( max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER ) + # When enabled on a link with PUBLIC/AUTHENTICATED reach, the link grants + # upload-only (write-only) access: recipients may add files but cannot list, + # download, or edit existing contents. Enables "file request" collection + # without exposing the folder. See issue #770. + link_upload_only = models.BooleanField(default=False) creator = models.ForeignKey( User, on_delete=models.RESTRICT, @@ -1296,18 +1301,27 @@ def get_abilities(self, user): link_definition = self.computed_link_definition link_reach = link_definition["link_reach"] - if link_reach == LinkReachChoices.PUBLIC or ( + link_applies = link_reach == LinkReachChoices.PUBLIC or ( link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated - ): + ) + # An upload-only ("file request") link grants create-only access: it must + # NOT raise the user's role (which would grant read/list/download), it only + # permits uploads into the folder. See issue #770. + is_upload_only = link_applies and self.link_upload_only + if link_applies and not is_upload_only: # Set the user role to the highest role between the item role and the link role # Needed for a user with an access lower than link_role # Needed for a user without access to determine the role he has. role = RoleChoices.max(role, link_definition["link_role"]) can_get = bool(role) and not is_deleted - retrieve = can_get or is_owner + # Upload-only recipients can see the target folder itself (to upload into + # it) but not its contents: children_list/download stay tied to can_get. + retrieve = can_get or is_owner or is_upload_only can_manage = is_owner_or_admin and not is_deleted can_update = (is_owner_or_admin or role == RoleChoices.EDITOR) and not is_deleted - can_create_children = can_update and user.is_authenticated + # Upload-only links intentionally allow anonymous creation, unlike the + # normal path which requires an authenticated editor. + can_create_children = (can_update and user.is_authenticated) or is_upload_only can_hard_delete = ( is_owner if self.is_root diff --git a/src/backend/core/tests/test_models_items.py b/src/backend/core/tests/test_models_items.py index b5fadc2b3..440b5c81b 100644 --- a/src/backend/core/tests/test_models_items.py +++ b/src/backend/core/tests/test_models_items.py @@ -1394,3 +1394,43 @@ def test_models_items_restore_complex_bis(): assert item.ancestors_deleted_at == item.deleted_at assert child1.ancestors_deleted_at == item.deleted_at assert child2.ancestors_deleted_at == item.deleted_at + + +def test_models_items_get_abilities_upload_only_link_anonymous(): + """ + An upload-only ("file request") public link lets an anonymous user create + children (upload) but not list, download, or edit existing contents. See #770. + """ + item = factories.ItemFactory( + link_reach="public", + link_role="reader", + link_upload_only=True, + type=models.ItemTypeChoices.FOLDER, + ) + abilities = item.get_abilities(AnonymousUser()) + + # Can contribute files... + assert abilities["children_create"] is True + # ...but cannot see or touch what is already there. + assert abilities["children_list"] is False + assert abilities["download"] is False + assert abilities["update"] is False + assert abilities["accesses_view"] is False + # Can still see the target folder itself, in order to upload into it. + assert abilities["retrieve"] is True + + +def test_models_items_get_abilities_upload_only_ignored_when_restricted(): + """ + The upload-only flag only takes effect for a link that actually applies + (PUBLIC / AUTHENTICATED reach); a restricted link grants nothing anonymously. + """ + item = factories.ItemFactory( + link_reach="restricted", + link_upload_only=True, + type=models.ItemTypeChoices.FOLDER, + ) + abilities = item.get_abilities(AnonymousUser()) + + assert abilities["children_create"] is False + assert abilities["retrieve"] is False