Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"link_upload_only",
"nb_accesses",
"numchild",
"numchild_folder",
Expand Down Expand Up @@ -282,6 +283,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"link_upload_only",
"nb_accesses",
"path",
"updated_at",
Expand Down Expand Up @@ -478,6 +480,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"link_upload_only",
"nb_accesses",
"numchild",
"numchild_folder",
Expand Down Expand Up @@ -583,6 +586,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"link_upload_only",
"nb_accesses",
"numchild",
"numchild_folder",
Expand Down Expand Up @@ -614,6 +618,7 @@ class Meta:
"is_favorite",
"link_role",
"link_reach",
"link_upload_only",
"nb_accesses",
"path",
"updated_at",
Expand Down Expand Up @@ -746,6 +751,7 @@ class Meta:
fields = [
"link_role",
"link_reach",
"link_upload_only",
]

def validate(self, attrs):
Expand Down
15 changes: 15 additions & 0 deletions src/backend/core/migrations/0025_item_link_upload_only.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
22 changes: 18 additions & 4 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions src/backend/core/tests/test_models_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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