From 90004d454064b1e053ba6e62bece5597a9d44d50 Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 17:27:37 +0200 Subject: [PATCH] nickel --- .../migrations/0017_granular_permissions.py | 10 ++ backend/drive/models.py | 4 + backend/drive/permissions.py | 84 +++++++++++ backend/drive/quotas.py | 134 ++++++------------ backend/drive/services.py | 34 +++-- backend/drive/spaces.py | 54 ++----- backend/drive/test_granular_permissions.py | 112 +++++++++++++++ backend/drive/urls.py | 1 + backend/drive/views_content.py | 5 +- backend/drive/views_items.py | 17 +-- backend/drive/views_shares.py | 70 +++++++-- backend/drive/views_spaces.py | 57 +++++--- backend/drive/views_transfers.py | 11 +- frontend/src/api/client.js | 7 +- .../src/components/drive/ContextActions.jsx | 13 +- .../src/components/drive/DriveSidebar.jsx | 39 ++--- .../components/drive/PermissionsEditor.jsx | 32 +++++ .../drive/dialogs/ItemActionsDialog.jsx | 11 +- .../drive/dialogs/ItemFormDialog.jsx | 2 +- .../drive/dialogs/PreviewDialog.jsx | 2 +- .../drive/dialogs/ShareItemDialog.jsx | 11 +- .../drive/dialogs/SpaceSettingsDialog.jsx | 6 +- frontend/src/hooks/useDriveController.js | 8 +- frontend/src/pages/MembersPage.jsx | 52 +++---- frontend/src/pages/PublicSharePage.jsx | 87 ++++++++---- 25 files changed, 574 insertions(+), 289 deletions(-) create mode 100644 backend/drive/migrations/0017_granular_permissions.py create mode 100644 backend/drive/permissions.py create mode 100644 backend/drive/test_granular_permissions.py create mode 100644 frontend/src/components/drive/PermissionsEditor.jsx diff --git a/backend/drive/migrations/0017_granular_permissions.py b/backend/drive/migrations/0017_granular_permissions.py new file mode 100644 index 0000000..aa58389 --- /dev/null +++ b/backend/drive/migrations/0017_granular_permissions.py @@ -0,0 +1,10 @@ +from django.db import migrations, models + +class Migration(migrations.Migration): + dependencies = [('drive', '0016_archived_drive_account')] + operations = [ + migrations.AddField(model_name='drivespace', name='parent_permissions', field=models.JSONField(null=True, blank=True, default=None)), + migrations.AddField(model_name='spacegrant', name='permissions', field=models.JSONField(null=True, blank=True, default=None)), + migrations.AddField(model_name='spaceinvitation', name='permissions', field=models.JSONField(null=True, blank=True, default=None)), + migrations.AddField(model_name='itemgrant', name='permissions', field=models.JSONField(null=True, blank=True, default=None)), + ] diff --git a/backend/drive/models.py b/backend/drive/models.py index 8ab84de..511f0f5 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -19,6 +19,7 @@ class DriveSpace(models.Model): display_name = models.CharField(max_length=120) can_view_parent = models.BooleanField(default=True) can_write_parent = models.BooleanField(default=False) + parent_permissions = models.JSONField(null=True, blank=True, default=None) can_invite = models.BooleanField(default=False) quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) created_at = models.DateTimeField(auto_now_add=True) @@ -33,6 +34,7 @@ class SpaceGrant(models.Model): space = models.ForeignKey(DriveSpace, on_delete=models.CASCADE, related_name='access_grants') can_write = models.BooleanField(default=False) can_invite = models.BooleanField(default=False) + permissions = models.JSONField(null=True, blank=True, default=None) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -48,6 +50,7 @@ class SpaceInvitation(models.Model): can_write_parent = models.BooleanField(default=False) can_invite = models.BooleanField(default=False) quota_bytes = models.BigIntegerField(null=True, blank=True, default=None) + permissions = models.JSONField(null=True, blank=True, default=None) expires_at = models.DateTimeField() used_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='redeemed_invitations') used_at = models.DateTimeField(null=True, blank=True) @@ -109,6 +112,7 @@ class ItemGrant(models.Model): item = models.ForeignKey(DriveItem, on_delete=models.CASCADE, related_name='user_shares') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='shared_drive_items') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+') + permissions = models.JSONField(null=True, blank=True, default=None) created_at = models.DateTimeField(auto_now_add=True) class Meta: diff --git a/backend/drive/permissions.py b/backend/drive/permissions.py new file mode 100644 index 0000000..7dbdb15 --- /dev/null +++ b/backend/drive/permissions.py @@ -0,0 +1,84 @@ +"""Granular, non-escalating rights for spaces and individual shares.""" +from .models import ItemGrant, SpaceGrant + +RIGHTS = ('view', 'download', 'add', 'modify', 'delete', 'share', 'manage_access', 'create_spaces', 'manage_space') +PRESETS = { + 'reader': ('view', 'download'), + 'contributor': ('view', 'download', 'add', 'modify'), + 'manager': RIGHTS, +} + + +def legacy_rights(read=True, write=False, invite=False): + result = ['view', 'download'] if read else [] + if write: + result += ['add', 'modify', 'delete', 'share'] + if invite: + result += ['manage_access', 'create_spaces'] + return result + + +def normalized(value): + if not isinstance(value, list) or len(value) > len(RIGHTS) or any(not isinstance(k, str) or k not in RIGHTS for k in value): + raise ValueError('Liste des permissions invalide.') + rights = set(value) + if 'view' not in rights: + raise ValueError('La permission Consulter est obligatoire.') + return sorted(rights, key=RIGHTS.index) + + +def _is_admin(user): + return user.is_authenticated and user.is_active and (user.is_staff or user.is_superuser) + + +def space_rights(user, space): + if not user.is_authenticated or not user.is_active or space is None: + return set() + if space.private_owner_id: + # Never expose someone else's private contents, including to admins. + return set(RIGHTS) if space.private_owner_id == user.pk else set() + if _is_admin(user) or space.managed_by_id == user.pk: + return set(RIGHTS) + if space.user_id == user.pk: + return set(RIGHTS) + grant = SpaceGrant.objects.filter(space=space, user=user).first() + if grant: + return set(grant.permissions) if grant.permissions is not None else set(legacy_rights(write=grant.can_write, invite=grant.can_invite)) + if space.parent_id and space.can_view_parent and space.user_id == user.pk: + return set(legacy_rights(write=space.can_write_parent, invite=space.can_invite)) + # Historical assigned-space access (parent) for legacy sub-users. + owned = getattr(user, 'drive_space', None) + if owned and owned.parent_id == space.pk and owned.can_view_parent: + return set(owned.parent_permissions) if owned.parent_permissions is not None else set(legacy_rights(write=owned.can_write_parent, invite=owned.can_invite)) + return set() + + +def has_space_right(user, space, right): + return right in space_rights(user, space) + + +def item_rights(user, item): + rights = space_rights(user, item.space) + if rights: + return rights + if not user.is_authenticated or not user.is_active: + return set() + # Share on a parent folder propagates to its descendants. + seen = set() + current = item + while current is not None: + if current.pk in seen: + return set() + seen.add(current.pk) + grant = ItemGrant.objects.filter(user=user, item=current).first() + if grant: + return set(grant.permissions) if grant.permissions is not None else set(PRESETS['reader']) + current = current.parent + return set() + + +def assert_delegation(issuer_rights, desired): + clean = set(normalized(desired)) + if not clean.issubset(set(issuer_rights)): + raise ValueError('Tu ne peux pas attribuer des permissions supérieures aux tiennes.') + return normalized(desired) diff --git a/backend/drive/quotas.py b/backend/drive/quotas.py index 4279ba0..ccb86f0 100644 --- a/backend/drive/quotas.py +++ b/backend/drive/quotas.py @@ -105,107 +105,61 @@ def trash_occupancy(space): return int(bytes_in_trash) -def _budget_tree(space): - """One DB snapshot of all descendant spaces and their direct physical usage. - - Shared descendants retain the existing reservation rules for assigned - quotas. A private space's 50-Go cap is NEVER preallocated: its parent counts - only its real files and pending uploads (including its own descendants). - """ - ids=list(subtree_space_ids(space)) - nodes={node.pk:node for node in DriveSpace.objects.filter(pk__in=ids)} - children={pk:[] for pk in nodes} - for node in nodes.values(): - if node.parent_id in children:children[node.parent_id].append(node.pk) - direct={pk:0 for pk in nodes} - for pk,amount in DriveItem.objects.filter(space_id__in=ids,is_folder=False).values('space_id').annotate(total=Sum('size')).values_list('space_id','total'): - direct[pk]+=int(amount or 0) - for pk,amount in UploadSession.objects.filter(space_id__in=ids).values('space_id').annotate(total=Sum('total_size')).values_list('space_id','total'): - direct[pk]+=int(amount or 0) - memo={} - def committed(pk): - if pk not in memo: - memo[pk] = direct[pk] + sum( - committed(child) if nodes[child].private_owner_id - else max(nodes[child].quota_bytes or 0, committed(child)) - for child in children[pk] - ) - return memo[pk] - committed(space.pk) - return memo - - -def _committed(space): - """Allocated bytes inside an entire subtree, including nested quota reservations.""" - return _budget_tree(space)[space.pk] - - def available_for_growth(space): - """Bytes that can be added directly to space without breaking any ancestor allocation. - - Quotas of normal assigned children can absorb growth already reserved in - their ancestors. For private children, the 50-Go cap is NOT reserved: every - newly uploaded byte immediately reduces the parent's available budget. - """ - maximum=MAX_QUOTA_BYTES - absorbed=0 - chain=ancestry(space) - committed_tree=_budget_tree(chain[-1]) - for ancestor in chain: - committed=committed_tree[ancestor.pk] - if ancestor.quota_bytes is not None: - maximum=min(maximum,ancestor.quota_bytes-committed+absorbed) - # Private caps constrain uploads, but never absorb parent capacity. - if not ancestor.private_owner_id: - absorbed+=max(0,ancestor.quota_bytes-committed) - return None if maximum==MAX_QUOTA_BYTES else max(0,int(maximum)) + """Unreserved quotas: every upload consumes capacity in its space and ALL ancestors.""" + limits = [] + for node in ancestry(space): + if node.quota_bytes is not None: + used, pending = occupancy(node) + limits.append(node.quota_bytes - used - pending) + return max(0, min(limits)) if limits else None def assert_new_child_quota(parent, quota): - """A new nested space cannot reserve more than its parent's unallocated capacity.""" - if quota is None:return - remaining=available_for_growth(parent) - if remaining is not None and quota>remaining: - raise ValueError(f'Le quota dépasse les {remaining} octets disponibles dans l’espace parent.') + """A child limit may not exceed its parent's maximum, but reserves nothing.""" + if quota is None and parent.quota_bytes is not None: + raise ValueError('Le quota du sous-espace doit respecter la limite de son parent.') + if quota is not None: + limits = [node.quota_bytes for node in ancestry(parent) if node.quota_bytes is not None] + if limits and quota > min(limits): + raise ValueError('Le quota du sous-espace dépasse la limite de son parent.') def assert_updated_quota(space, quota): - """Validate nested allocations, including sibling limits and actual disk use. - - The old reservation is replaced atomically with the requested value. Parent - quotas must be large enough for their direct content and all child quotas. - """ - chain=ancestry(space) - committed_tree=_budget_tree(chain[-1]) - committed=committed_tree[space.pk] - if quota is not None and quotaancestor.quota_bytes: - raise ValueError(f'Quota impossible : capacité de « {ancestor.display_name} » dépassée.') - old_child=max(ancestor.quota_bytes or 0,committed_tree[ancestor.pk]) - new_child=max(ancestor.quota_bytes or 0,amount) + """Validate actual occupancy, nested maximums, never preallocate capacity.""" + used, pending = occupancy(space) + if quota is not None and quota < used + pending: + raise ValueError('Le quota ne peut pas être inférieur au stockage utilisé.') + if space.parent_id: + assert_new_child_quota(space.parent, quota) + if quota is not None: + # Keep the hierarchy valid if the parent limit shrinks. + descendants = subtree_space_ids(space) - {space.pk} + existing = DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=False) + if existing.filter(quota_bytes__gt=quota).exists(): + raise ValueError('Le quota est inférieur à la limite d’un sous-espace existant.') + if quota == 0 and DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True, quota_bytes__isnull=True).exists(): + raise ValueError('Un sous-espace sans limite empêche ce changement.') def quota_state(space): - used,reserved=occupancy(space) - trashed=trash_occupancy(space) - available=available_for_growth(space) + used, reserved = occupancy(space) + trashed = trash_occupancy(space) + available = available_for_growth(space) + # This number indicates effective capacity already unavailable within a + # child cap, including bytes used elsewhere in a shared parent. + effective_used = used + if not space.private_owner_id and space.quota_bytes is not None and available is not None: + effective_used = max(used + reserved, space.quota_bytes - available) return { - 'quota_bytes':space.quota_bytes, - 'used_bytes':used, - 'active_bytes':max(0,used-trashed), - 'trashed_bytes':trashed, - 'reserved_bytes':reserved, - 'available_bytes':available, - 'effective_limit_bytes':None if available is None else used+reserved+available, + 'quota_bytes': space.quota_bytes, + 'used_bytes': max(0, effective_used - reserved), + 'actual_used_bytes': used, + 'active_bytes': max(0, used - trashed), + 'trashed_bytes': trashed, + 'reserved_bytes': reserved, + 'available_bytes': available, + 'effective_limit_bytes': None if available is None else effective_used + available, } diff --git a/backend/drive/services.py b/backend/drive/services.py index 498499c..afc1dd6 100644 --- a/backend/drive/services.py +++ b/backend/drive/services.py @@ -5,6 +5,7 @@ from django.http import Http404 from rest_framework.response import Response from .models import DriveItem, ItemGrant from .spaces import can_read_space, can_write_space +from .permissions import has_space_right, item_rights def error(message, code=400): @@ -22,6 +23,7 @@ def item_info(item, user=None): } if user is not None: data['can_write'] = writable(item,user) + data['permissions'] = sorted(item_rights(user, item)) return data @@ -45,18 +47,13 @@ def chain(item): def readable(item, user, allow_trashed=False): - space_allowed = can_read_space(user,item.space) - if not space_allowed and getattr(user,'is_authenticated',False) and user.is_active: - space_allowed = ItemGrant.objects.filter(user=user,item_id__in=[node.pk for node in chain(item)]).exists() - if not space_allowed: + if 'view' not in item_rights(user, item): return False - if not allow_trashed and any(n.trashed_at is not None for n in chain(item)): - return False - return True + return allow_trashed or not any(n.trashed_at is not None for n in chain(item)) -def writable(item,user): - return can_write_space(user,item.space) +def writable(item, user, action='modify'): + return action in item_rights(user, item) def valid_name(name): @@ -77,7 +74,7 @@ def target_parent(parent_id, user, space): raise ValueError('Dossier de destination introuvable.') if parent.space_id != space.id: raise PermissionError('Impossible de déplacer un élément entre deux espaces.') - if not parent.is_folder or not readable(parent,user) or not writable(parent,user): + if not parent.is_folder or not readable(parent,user) or not writable(parent,user,'add'): raise PermissionError('Dossier de destination inaccessible en écriture.') return parent @@ -95,3 +92,20 @@ def descendant_files(node): key=node.storage_key files.append(node.storage_location or f'{key[:2]}/{key}') return files + + +def writable_destination(user, space_id, parent_id): + """Uploads may target a folder shared with this user, without space membership.""" + from .spaces import resolve_space + from .permissions import has_space_right + if parent_id: + folder=get_item(parent_id) + if space_id is not None and str(folder.space_id)!=str(space_id): + raise PermissionError('Le dossier ne correspond pas à cet espace.') + if not folder.is_folder or not readable(folder,user) or not writable(folder,user,'add'): + raise PermissionError('Dossier inaccessible en ajout.') + return folder.space, folder + space=resolve_space(user,space_id) + if not has_space_right(user,space,'add'): + raise PermissionError('Ajout interdit dans cet espace.') + return space,None diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py index 565020e..b42bb3b 100644 --- a/backend/drive/spaces.py +++ b/backend/drive/spaces.py @@ -7,6 +7,7 @@ from django.db import transaction from django.http import Http404 from .models import DriveSpace, SpaceGrant, DriveItem from .quotas import PRIVATE_SPACE_QUOTA_BYTES +from .permissions import has_space_right, space_rights def is_admin(user): @@ -120,53 +121,16 @@ def _grant(user, space): def can_read_space(user, space): - if space is None: - return False - if not user.is_authenticated or not user.is_active: - return False - # Personal files are never subject to administrative or inherited access. - if space.private_owner_id: - return space.private_owner_id == user.pk - if is_admin(user) or space.managed_by_id == user.pk: - return True - own = ensure_space(user) - if own and own.pk == space.pk and own.user_id == user.pk: - return True - if own and own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent: - return True - return _grant(user, space) is not None + return has_space_right(user, space, 'view') def can_write_space(user, space): - if space is None: - return False - if not user.is_authenticated or not user.is_active: - return False - # Personal files are never subject to administrative or inherited access. - if space.private_owner_id: - return space.private_owner_id == user.pk - if is_admin(user) or space.managed_by_id == user.pk: - return True - own = ensure_space(user) - if own and own.pk == space.pk and own.user_id == user.pk: - return True - if own and own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: - return True - grant = _grant(user, space) - return bool(grant and grant.can_write) + # Kept for legacy callers: use explicit right checks on each operation. + return has_space_right(user, space, 'add') or has_space_right(user, space, 'modify') def can_manage_space(user, space): - if space is None: - return False - if not user.is_authenticated or not user.is_active or space.private_owner_id: - return False - if is_admin(user) or space.managed_by_id == user.pk: - return True - if space.user_id == user.pk and space.can_invite: - return True - grant = _grant(user, space) - return bool(grant and grant.can_invite) + return has_space_right(user, space, 'manage_access') def resolve_space(user, space_id=None, write=False): @@ -185,7 +149,7 @@ def resolve_space(user, space_id=None, write=False): def space_info(space, user): from .quotas import quota_state - owner = space.private_owner or space.user or space.managed_by + owner = space.private_owner or space.user or space.managed_by or user return { 'id': space.pk, 'user_id': owner.pk, 'username': owner.username, 'first_name': owner.first_name, 'last_name': owner.last_name, @@ -193,11 +157,13 @@ def space_info(space, user): 'is_private': bool(space.private_owner_id), 'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space), 'can_invite': can_manage_space(user, space), - 'can_edit': not space.private_owner_id and (is_admin(user) or space.managed_by_id == user.pk or (space.user_id == user.pk and can_manage_space(user, space))), - 'can_delete': not space.private_owner_id and (is_admin(user) or (space.parent_id is not None and not space.user_id and space.managed_by_id == user.pk)), + 'permissions': sorted(space_rights(user, space)), + 'can_edit': not space.private_owner_id and has_space_right(user, space, 'manage_space'), + 'can_delete': not space.private_owner_id and has_space_right(user, space, 'manage_space') and (space.parent_id is not None or is_admin(user)), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, **quota_state(space), 'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent, + 'parent_permissions': space.parent_permissions if space.parent_permissions is not None else (['view','download'] + (['add','modify','delete','share'] if space.can_write_parent else []) + (['manage_access','create_spaces'] if space.can_invite else [])), 'created_at': space.created_at.isoformat(), } diff --git a/backend/drive/test_granular_permissions.py b/backend/drive/test_granular_permissions.py new file mode 100644 index 0000000..c632e5e --- /dev/null +++ b/backend/drive/test_granular_permissions.py @@ -0,0 +1,112 @@ +"""Run: USE_SQLITE=true python manage.py test drive.test_granular_permissions""" +import tempfile +import uuid +from pathlib import Path +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.core.files.uploadedfile import SimpleUploadedFile +from rest_framework.test import APIClient +from .models import DriveItem, DriveSpace, SpaceGrant, ItemGrant +from .spaces import ensure_space, ensure_private_space +from .quotas import quota_state, occupancy + + +class GranularPermissionTests(TestCase): + def setUp(self): + tmp=tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.dir=Path(tmp.name) + settings=override_settings(DRIVE_STORAGE_ROOT=self.dir, FILE_UPLOAD_TEMP_DIR=str(self.dir/'.uploads')) + settings.enable();self.addCleanup(settings.disable) + User=get_user_model() + self.admin=User.objects.create_superuser('test-admin','admin@localhost','ComplexPassword123!') + self.member=User.objects.create_user('test-member',password='ComplexPassword123!') + self.guest=User.objects.create_user('test-guest',password='ComplexPassword123!') + self.root=ensure_space(self.admin) + self.private=ensure_private_space(self.member) + self.client=APIClient() + self.client.force_authenticate(self.admin) + + def test_delegation_and_independent_rights(self): + rights=['view','download','manage_access'] + SpaceGrant.objects.create(user=self.member,space=self.root,permissions=rights,can_invite=True) + self.client.force_authenticate(self.member) + self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'NO'},format='json').status_code,403) + created=self.client.post('/api/drive/invitations/',{ + 'parent_space':self.root.pk,'permissions':['view','download']},format='json') + self.assertEqual(created.status_code,201,created.data) + escalation=self.client.post('/api/drive/invitations/',{ + 'parent_space':self.root.pk,'permissions':['view','modify']},format='json') + self.assertEqual(escalation.status_code,403) + no_create=self.client.post('/api/drive/spaces/create/',{ + 'parent_space':self.root.pk,'display_name':'Impossible','quota_bytes':None},format='json') + self.assertEqual(no_create.status_code,403) + self.client.force_authenticate(self.admin) + grant=self.client.post('/api/drive/spaces/users/',{ + 'parent_space':self.root.pk,'user_id':self.guest.pk, + 'permissions':['view','add']},format='json',QUERY_STRING=f'parent_space={self.root.pk}') + self.assertEqual(grant.status_code,201,grant.data) + self.client.force_authenticate(self.guest) + self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'OK'},format='json').status_code,201) + self.assertEqual(self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk},format='json').status_code,403) + + def test_granular_folder_share_and_anonymous_preview(self): + folder=DriveItem.objects.create(owner=self.member,space=self.private,name='Photos',is_folder=True) + image=DriveItem.objects.create(owner=self.member,space=self.private,parent=folder,name='photo.png', + storage_key=str(uuid.uuid4()),storage_location=f'{self.private.relative_path}/Photos/photo.png',size=8,mime_type='image/png') + filename=self.dir/image.storage_location + filename.parent.mkdir(parents=True,exist_ok=True) + filename.write_bytes(b'PNGDATA!') + self.client.force_authenticate(self.member) + share=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{ + 'user_id':self.guest.pk,'permissions':['view','download','add']},format='json') + self.assertEqual(share.status_code,201,share.data) + self.client.force_authenticate(self.guest) + self.assertEqual(self.client.post('/api/drive/folders/',{ + 'name':'Nouveau','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,201) + self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/trash/').status_code,403) + self.client.force_authenticate(self.member) + result=self.client.post(f'/api/drive/items/{image.pk}/public-links/',{},format='json') + self.assertEqual(result.status_code,201,result.data) + anonymous=APIClient() + token=result.data['token'] + inline=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1') + self.assertEqual(inline.status_code,200) + self.assertEqual(inline['Content-Type'],'image/png') + self.assertEqual(b''.join(inline.streaming_content),b'PNGDATA!') + partial=anonymous.get(f'/api/public/{token}/files/{image.pk}/?inline=1',HTTP_RANGE='bytes=0-2') + self.assertEqual(partial.status_code,206) + self.assertEqual(b''.join(partial.streaming_content),b'PNG') + self.client.force_authenticate(self.member) + self.assertEqual(self.client.patch(f'/api/drive/items/{folder.pk}/shares/{share.data["id"]}/permissions/',{ + 'permissions':['view']},format='json').status_code,200) + self.client.force_authenticate(self.guest) + self.assertEqual(self.client.post('/api/drive/folders/',{ + 'name':'Interdit','parent':str(folder.pk),'space':self.private.pk},format='json').status_code,403) + self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':False},format='json').status_code,403) + self.assertEqual(self.client.post(f'/api/drive/items/{image.pk}/link/',{'inline':True},format='json').status_code,200) + + def test_unreserved_child_quota_parent_usage_reduces_child_capacity(self): + gib=1024**3 + self.root.quota_bytes=50*gib + self.root.save(update_fields=['quota_bytes']) + self.client.force_authenticate(self.admin) + res=self.client.post('/api/drive/spaces/create/',{ + 'parent_space':self.root.pk,'display_name':'Sous espace','quota_bytes':30*gib},format='json') + self.assertEqual(res.status_code,201,res.data) + child=DriveSpace.objects.get(pk=res.data['id']) + self.assertEqual(child.parent_id,self.root.pk) + DriveItem.objects.create(space=self.root,owner=self.admin,name='parent.bin',size=20*gib, + storage_key=str(uuid.uuid4()),storage_location='unused') + self.assertEqual(quota_state(self.root)['used_bytes'],20*gib) + self.assertEqual(quota_state(child)['used_bytes'],0) + DriveItem.objects.filter(space=self.root).update(size=30*gib) + self.assertEqual(quota_state(self.root)['used_bytes'],30*gib) + self.assertEqual(quota_state(child)['used_bytes'],10*gib) + self.assertEqual(quota_state(child)['available_bytes'],20*gib) + DriveItem.objects.create(space=child,owner=self.admin,name='child.bin',size=10*gib, + storage_key=str(uuid.uuid4()),storage_location='unused2') + self.assertGreaterEqual(quota_state(self.root)['used_bytes'],10*gib) + self.assertEqual(quota_state(self.root)['used_bytes'],40*gib) + self.assertEqual(quota_state(child)['used_bytes'],20*gib) + self.assertEqual(occupancy(child)[0],10*gib) diff --git a/backend/drive/urls.py b/backend/drive/urls.py index c9b8c20..fc51ab0 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -10,6 +10,7 @@ urlpatterns=[ path('shares/users/search/',views_shares.search_share_users), path('items//shares/',views_shares.manage_item_shares), path('items//shares//',views_shares.remove_item_share), + path('items//shares//permissions/',views_shares.update_item_share), path('items//public-links/',views_shares.public_links), path('items//public-links//',views_shares.revoke_public_link), path('spaces/create/',views_spaces.create_shared_space), diff --git a/backend/drive/views_content.py b/backend/drive/views_content.py index a66e6fe..ee2cf43 100644 --- a/backend/drive/views_content.py +++ b/backend/drive/views_content.py @@ -10,6 +10,7 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from .services import error, get_item, readable from .spaces import storage_path +from .permissions import item_rights IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'} VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'} @@ -31,6 +32,8 @@ def safe_preview_type(item): def content_url(request,item_id): item=get_item(item_id) if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403) + if not request.data.get('inline',False) and 'download' not in item_rights(request.user,item): + return error('Téléchargement non autorisé.',403) inline=bool(request.data.get('inline',False)) if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415) # Encode the authorized identity so access is checked again on every request. @@ -62,7 +65,7 @@ def content(request,item_id): except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist): raise Http404 item=get_item(item_id) - if item.is_folder or not readable(item,user):raise Http404 + if item.is_folder or not readable(item,user) or (inline=='0' and 'download' not in item_rights(user,item)):raise Http404 media=safe_preview_type(item) if inline=='1' and not media:raise Http404 path=storage_path(item) diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index 6e81fe6..df3016c 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -9,8 +9,9 @@ from rest_framework.decorators import api_view from rest_framework.response import Response from .models import DriveItem, DriveSpace, ItemGrant from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space +from .permissions import has_space_right, item_rights from .quotas import quota_state -from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files +from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, writable_destination, descendant_files from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict @@ -83,7 +84,8 @@ def listing(request): for n in ancestors: trail.append({'id':str(n.id),'name':n.name}) return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500, - 'can_write':can_write_space(user,space) if space else False,'space_id':space.pk if space else None}) + 'can_write':('add' in item_rights(user,parent)) if view=='shared' and parent else (has_space_right(user,space,'add') if space else False), + 'space_id':parent.space_id if view=='shared' and parent else (space.pk if space else None)}) @api_view(['GET']) @@ -100,9 +102,8 @@ def stats(request): @api_view(['POST']) def create_folder(request): try: - space=resolve_space(request.user,request.data.get('space'),write=True) name=valid_name(request.data.get('name')) - parent=target_parent(request.data.get('parent'),request.user,space) + space,parent=writable_destination(request.user,request.data.get('space'),request.data.get('parent')) except ValueError as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) with transaction.atomic(): @@ -126,7 +127,7 @@ def create_folder(request): @api_view(['PATCH']) def update_item(request,item_id): item=get_item(item_id) - if not readable(item,request.user) or not writable(item,request.user): + if not readable(item,request.user) or not writable(item,request.user,'modify'): return error('Modification interdite.',403) data=request.data try: @@ -153,7 +154,7 @@ def update_item(request,item_id): @api_view(['POST']) def trash_item(request,item_id): item=get_item(item_id) - if not readable(item,request.user) or not writable(item,request.user):return error('Accès refusé.',403) + if not readable(item,request.user) or not writable(item,request.user,'delete'):return error('Accès refusé.',403) if item.trashed_at:return error('Élément déjà dans la corbeille.') item.trashed_at=timezone.now();item.save(update_fields=['trashed_at','updated_at']) return Response({'ok':True}) @@ -162,7 +163,7 @@ def trash_item(request,item_id): @api_view(['POST']) def restore_item(request,item_id): item=get_item(item_id) - if not writable(item,request.user):return error('Accès refusé.',403) + if not writable(item,request.user,'delete'):return error('Accès refusé.',403) if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.') if item.parent and any(n.trashed_at for n in chain(item.parent)): try: @@ -176,7 +177,7 @@ def restore_item(request,item_id): @api_view(['DELETE']) def delete_item(request,item_id): item=get_item(item_id) - if not writable(item,request.user):return error('Accès refusé.',403) + if not writable(item,request.user,'delete'):return error('Accès refusé.',403) if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.') files=descendant_files(item) folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py index 17ccd0b..f743c18 100644 --- a/backend/drive/views_shares.py +++ b/backend/drive/views_shares.py @@ -7,7 +7,7 @@ import hashlib import secrets from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError -from django.http import FileResponse, Http404 +from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse from django.db.models import Q from django.utils import timezone from rest_framework.decorators import api_view, permission_classes, throttle_classes @@ -17,15 +17,17 @@ from rest_framework.response import Response from .models import DriveItem, ItemGrant, PublicItemLink from .services import error, get_item, readable, writable, chain, item_info from .spaces import storage_path +from .permissions import item_rights, assert_delegation, PRESETS, normalized +from .views_content import safe_preview_type, read_bytes def shareable(item, user): - return readable(item, user) and writable(item, user) + return readable(item, user) and 'share' in item_rights(user,item) def share_info(grant): u=grant.user - return {'id': grant.pk,'user_id':u.pk,'username':u.username, + return {'id': grant.pk,'permissions':grant.permissions if grant.permissions is not None else list(PRESETS['reader']),'user_id':u.pk,'username':u.username, 'first_name':u.first_name,'last_name':u.last_name} @@ -58,11 +60,26 @@ def manage_item_shares(request,item_id): try:account=get_user_model().objects.get(pk=user_id,is_active=True) except get_user_model().DoesNotExist:return error('Utilisateur introuvable.',404) if account.pk==request.user.pk:return error('Cet élément t’appartient déjà.') - grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user}) + try: + rights=assert_delegation(item_rights(request.user,item),request.data.get('permissions',list(PRESETS['reader']))) + except ValueError as exc:return error(str(exc),403) + grant,created=ItemGrant.objects.get_or_create(item=item,user=account,defaults={'created_by':request.user,'permissions':rights}) if not created:return error('Cet élément est déjà partagé avec cet utilisateur.',409) return Response(share_info(grant),status=201) +@api_view(['PATCH']) +def update_item_share(request,item_id,grant_id): + item=get_item(item_id) + if not shareable(item,request.user):return error('Partage non autorisé.',403) + try: + rights=assert_delegation(item_rights(request.user,item), request.data.get('permissions')) + except ValueError as exc:return error(str(exc),403) + changed=ItemGrant.objects.filter(item=item,pk=grant_id).update(permissions=rights) + if not changed:return error('Partage introuvable.',404) + return Response(share_info(ItemGrant.objects.select_related('user').get(pk=grant_id))) + + @api_view(['DELETE']) def remove_item_share(request,item_id,grant_id): item=get_item(item_id) @@ -75,7 +92,7 @@ def remove_item_share(request,item_id,grant_id): @api_view(['POST']) def public_links(request,item_id): item=get_item(item_id) - if not shareable(item,request.user) or not readable(item,request.user): + if not shareable(item,request.user) or not {'view','download','share'}.issubset(item_rights(request.user,item)): return error('Partage non autorisé.',403) token=secrets.token_urlsafe(32) PublicItemLink.objects.create(item=item,token_hash=hashlib.sha256(token.encode()).hexdigest(),created_by=request.user) @@ -118,10 +135,10 @@ def public_share(request,token): else:folder=root if not folder.is_folder or not in_public_tree(root,folder): if not parent_id and not root.is_folder: - return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False},'items':[]}) + return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':False,'size':root.size,'mime_type':root.mime_type},'items':[]}) raise Http404 qs=DriveItem.objects.filter(parent=folder,trashed_at__isnull=True).order_by('-is_folder','name')[:501] - items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size} for i in qs] + items=[{'id':str(i.pk),'name':i.name,'is_folder':i.is_folder,'size':i.size,'mime_type':i.mime_type} for i in qs] # Never list ancestors outside the shared root. breadcrumb=[] for node in reversed(chain(folder)): @@ -135,14 +152,49 @@ def public_share(request,token): @permission_classes([AllowAny]) @throttle_classes([AnonRateThrottle]) def public_file(request,token,item_id): + """Bearer-link file access; inline previews only for safe MIME types.""" + import re root=get_public_root(token) item=get_item(item_id) if item.is_folder or not in_public_tree(root,item):raise Http404 path=storage_path(item) if not path.is_file():raise Http404 - response=FileResponse(path.open('rb'),as_attachment=True,filename=item.name, - content_type='application/octet-stream') + inline=request.query_params.get('inline')=='1' + preview=safe_preview_type(item) + if inline and not preview:return error('Aperçu indisponible pour ce format.',415) + content_type=preview if inline else 'application/octet-stream' + size=path.stat().st_size + byte_range=request.headers.get('Range') + if byte_range: + match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip()) + if not match or (not match[1] and not match[2]): + response=HttpResponse(status=416) + response['Content-Range']=f'bytes */{size}' + return response + if match[1]: + start=int(match[1]);end=int(match[2]) if match[2] else size-1 + else: + start=max(0,size-int(match[2]));end=size-1 + end=min(end,size-1) + if start>=size or endsettings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413) @@ -131,7 +130,7 @@ def upload_finish(request,upload_id): src.touch(exist_ok=True) if not src.exists() or src.stat().st_size!=job.total_size: return error('Le fichier temporaire est incomplet.') - if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))): + if (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user,'add'))) or (not job.parent and not has_space_right(request.user,job.space,'add')): return error('Dossier de destination indisponible.',403) key=str(uuid.uuid4()) location=file_location(job.space,job.parent,job.name) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 82ae4ed..5de0a46 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -66,7 +66,8 @@ export const drive={ list:params=>api.get('/drive/items/',{params}).then(r=>r.data), shareUsers:q=>api.get('/drive/shares/users/search/',{params:{q}}).then(r=>r.data.users), itemShares:id=>api.get(`/drive/items/${id}/shares/`).then(r=>r.data), - shareItem:(id,user_id)=>api.post(`/drive/items/${id}/shares/`,{user_id}).then(r=>r.data), + shareItem:(id,user_id,permissions)=>api.post(`/drive/items/${id}/shares/`,{user_id,permissions}).then(r=>r.data), + shareItemPermissions:(id,grant_id,permissions)=>api.patch(`/drive/items/${id}/shares/${grant_id}/permissions/`,{permissions}).then(r=>r.data), unshareItem:(id,grant_id)=>api.delete(`/drive/items/${id}/shares/${grant_id}/`), publicItem:id=>api.post(`/drive/items/${id}/public-links/`).then(r=>r.data), revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`), @@ -112,7 +113,7 @@ export const drive={ deleteSpace:(id,confirm_name)=>api.delete(`/drive/spaces/${id}/`,{data:{confirm_name}}).then(r=>r.data), renameSpace:display_name=>api.patch('/drive/spaces/me/',{display_name}).then(r=>r.data), searchUsers:(q,parent_space)=>api.get('/drive/spaces/users/search/',{params:{q,parent_space}}).then(r=>r.data.users), - addUser:(user_id,parent_space,can_write=false,can_invite=false)=>api.post('/drive/spaces/users/',{user_id,can_write,can_invite},{params:{parent_space}}).then(r=>r.data), + addUser:(user_id,parent_space,permissions)=>api.post('/drive/spaces/users/',{user_id,permissions},{params:{parent_space}}).then(r=>r.data), grantPatch:(id,data)=>api.patch(`/drive/spaces/users/${id}/`,data).then(r=>r.data), grantRemove:id=>api.delete(`/drive/spaces/users/${id}/`), members:parent_space=>api.get('/drive/spaces/members/',{params:parent_space?{parent_space}:{}}).then(r=>r.data), @@ -125,4 +126,4 @@ export const drive={ } export function message(error){let d=error?.response?.data;if(typeof d?.detail==='string')return d.detail;if(typeof d==='object' && d!==null)return Object.entries(d).map(([k,v])=>`${k}: ${Array.isArray(v)?v.join(', '):v}`).join(' · ');return 'Une erreur est survenue. Réessaie.'} -export const publicFileUrl = (token,id) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/` +export const publicFileUrl = (token,id,inline=false) => `${publicApi.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/${inline?'?inline=1':''}` diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index a17043e..3032945 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -4,23 +4,24 @@ import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users export const fileOptions = (item, trash) => { if (!item) return [] - if (trash) return item.can_write ? [ + const rights = new Set(item.permissions || []) + if (trash) return rights.has('delete') ? [ { id: 'restore', title: 'Restaurer', icon: RotateCcw }, { id: 'confirmdelete', title: 'Supprimer définitivement', icon: Trash2, danger: true }, ] : [] return [ { id: 'open', title: 'Ouvrir', icon: Eye }, - ...(!item.is_folder ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []), - ...(item.can_write ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []), - ...(item.can_write ? [ + ...(!item.is_folder && rights.has('download') ? [{ id: 'download', title: 'Télécharger', icon: Download }] : []), + ...(rights.has('share') ? [{ id: 'share', title: 'Partager', icon: Share2 }] : []), + ...(rights.has('modify') ? [ { id: 'rename', title: 'Renommer', icon: Pencil }, { id: 'move', title: 'Déplacer', icon: FolderInput }, - { id: 'trash', title: 'Mettre à la corbeille', icon: Trash2, danger: true }, ] : []), + ...(rights.has('delete') ? [{id:'trash',title:'Mettre à la corbeille',icon:Trash2,danger:true}] : []), ] } -export const spaceOptions = (space) => (space?.can_invite || space?.can_delete) ? [ +export const spaceOptions = (space) => (space?.can_invite || space?.can_delete || space?.can_edit) ? [ ...(space.can_edit ? [{id:'manage',title:'Paramètres de l’espace',icon:Settings2}] : []), ...(space.can_invite ? [{id:'members',title:'Accès et invitations',icon:Users}] : []), ...(space.can_delete ? [{id:'delete',title:space.parent_id ? 'Supprimer l’espace' : 'Vider la racine',icon:Trash2,danger:true}] : []), diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index 48622e1..c276b73 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import ContextActions, {spaceOptions, mobileDots} from './ContextActions' -import { MoreHorizontal, Plus } from 'lucide-react' +import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from 'lucide-react' import { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X, Users, Share2 } from 'lucide-react' import { sections } from '@/config/navigation' import { fmtSize } from '@/lib/drive-format' @@ -13,7 +13,7 @@ export default function DriveSidebar({ controller, user, onLogout }) { const [spaceMenu,setSpaceMenu] = useState(null) const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[]) useEffect(()=>{ - if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete))) closeSpaceMenu() + if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete || space.can_edit))) closeSpaceMenu() },[spaces,spaceMenu,closeSpaceMenu]) const showSpaceMenu=(space,x,y)=>{ const options=spaceOptions(space) @@ -46,8 +46,8 @@ export default function DriveSidebar({ controller, user, onLogout }) { const orphaned = pendingUploads.filter(job => !currentSessions.has(job.id)) const personal = spaces.find(space => space.is_private && space.user_id === user.id) const shared = spaces.filter(space => !space.is_private) - const manageable=shared.filter(space=>space.can_invite) - const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.can_invite&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)} + const manageable=shared.filter(space=>(space.permissions || []).includes('create_spaces')) + const createSpace=()=>{setSpaceDialog({mode:'create',parentId:activeSpace?.permissions?.includes('create_spaces')&&!activeSpace.is_private ? activeSpaceId : manageable[0]?.id});setSidebar(false)} const newFolder = () => { setValue('') @@ -86,18 +86,8 @@ export default function DriveSidebar({ controller, user, onLogout }) {

Espaces partagés

{manageable.length>0 && } -
- {shared.map(space =>
{if(space.can_invite||space.can_delete){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> - - {(space.can_invite || space.can_delete) && } -
)} +
+ {shared.filter(space=>!shared.some(ancestor=>ancestor.id===space.parent_id)).map(space=>) }

Explorer @@ -175,3 +165,20 @@ export default function DriveSidebar({ controller, user, onLogout }) { ) } + +function SpaceTreeNode({space,shared,depth,activeSpaceId,switchSpace,showSpaceMenu,setSpaceMenu}) { + const [expanded,setExpanded]=useState(true) + const children=shared.filter(node=>node.parent_id===space.id && node.id!==space.id) + return

+
{if(space.can_invite||space.can_delete||space.can_edit){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> + {children.length>0 ? : } + + {(space.can_invite||space.can_delete||space.can_edit) && } +
+ {expanded && children.map(node=>) } +
+} diff --git a/frontend/src/components/drive/PermissionsEditor.jsx b/frontend/src/components/drive/PermissionsEditor.jsx new file mode 100644 index 0000000..f51daa1 --- /dev/null +++ b/frontend/src/components/drive/PermissionsEditor.jsx @@ -0,0 +1,32 @@ +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' + +export const RIGHTS = [ + ['view','Consulter'], ['download','Télécharger'], ['add','Ajouter'], + ['modify','Modifier'], ['delete','Supprimer'], ['share','Partager'], + ['manage_access','Gérer les accès'], ['create_spaces','Créer des espaces'], ['manage_space','Gérer l’espace'], +] +export const PRESETS = { + reader: ['view','download'], + contributor: ['view','download','add','modify'], + manager: RIGHTS.map(([id])=>id), +} +export default function PermissionsEditor({value=[],onChange,maxRights=RIGHTS.map(([id])=>id),compact=false}) { + const maximum = new Set(maxRights || []) + const selected = new Set(value || []) + const update = name => { + if(!maximum.has(name))return + if(name==='view' && selected.has('view'))return + if(selected.has(name))selected.delete(name) + else {selected.add(name);selected.add('view')} + onChange(RIGHTS.map(([id])=>id).filter(k=>selected.has(k)&&maximum.has(k))) + } + return
+
{Object.entries(PRESETS).map(([key,rights])=>)}
+
+ {RIGHTS.filter(([key])=>maximum.has(key)).map(([key,label])=>)} +
+
+} diff --git a/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx b/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx index 4b471bd..33f09d9 100644 --- a/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemActionsDialog.jsx @@ -10,6 +10,7 @@ const ActionButton = ({ icon: Icon, children, destructive = false, ...props }) = export default function ItemActionsDialog({ controller: c }) { const { modal, setModal, action, handleAction, isTrash } = c + const rights = new Set(action?.permissions || []) return ( { if (!open) setModal(null) }}> @@ -20,14 +21,14 @@ export default function ItemActionsDialog({ controller: c }) {
{action && <> {!isTrash && handleAction('open')}>Ouvrir} - {!action.is_folder && handleAction('download')}>Télécharger} - {action.can_write && !isTrash && <> - handleAction('share')}>Partager + {!action.is_folder && rights.has('download') && handleAction('download')}>Télécharger} + {!isTrash && rights.has('share') && handleAction('share')}>Partager} + {!isTrash && rights.has('modify') && <> handleAction('rename')}>Renommer handleAction('move')}>Déplacer - handleAction('trash')}>Corbeille } - {action.can_write && isTrash && <> + {!isTrash && rights.has('delete') && handleAction('trash')}>Corbeille} + {rights.has('delete') && isTrash && <> handleAction('restore')}>Restaurer handleAction('confirmdelete')}>Supprimer } diff --git a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx index 85e240c..3244077 100644 --- a/frontend/src/components/drive/dialogs/ItemFormDialog.jsx +++ b/frontend/src/components/drive/dialogs/ItemFormDialog.jsx @@ -6,6 +6,6 @@ import { HardDrive, ChevronRight, Folder } from 'lucide-react' export default function ItemFormDialog({controller: c}) { const {modal, setModal, action, value, setValue, busy, moveBrowse, moveTrail, moveItems, actionSubmit} = c return ( - {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.can_write).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
+ {if(!v)setModal(null)}}>{modal==='folder'?'Créer un dossier':modal==='rename'?'Renommer':'Déplacer un élément'}{modal==='move' ? 'Choisis le dossier de destination.' : modal==='folder' ? 'Nom du nouveau dossier' : 'Nouveau nom'}
{modal==='move'?
{moveTrail.map((x,i)=>
)}

Destination : {moveTrail.at(-1)?.name||'Racine'}

{moveItems.filter(x=>x.id!==action?.id && x.permissions?.includes('add')).map(x=>)}
:setValue(e.target.value)} placeholder="Nom"/>}
) } diff --git a/frontend/src/components/drive/dialogs/PreviewDialog.jsx b/frontend/src/components/drive/dialogs/PreviewDialog.jsx index adfd5f2..9d5b95f 100644 --- a/frontend/src/components/drive/dialogs/PreviewDialog.jsx +++ b/frontend/src/components/drive/dialogs/PreviewDialog.jsx @@ -25,7 +25,7 @@ export default function PreviewDialog({controller: c}) {
{preview && setPreviewUrl('unsupported')}/>}
- {preview && }{preview && } + {preview && }{preview?.permissions?.includes('download') && }
} diff --git a/frontend/src/components/drive/dialogs/ShareItemDialog.jsx b/frontend/src/components/drive/dialogs/ShareItemDialog.jsx index c73d928..3146410 100644 --- a/frontend/src/components/drive/dialogs/ShareItemDialog.jsx +++ b/frontend/src/components/drive/dialogs/ShareItemDialog.jsx @@ -4,6 +4,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { drive, message } from '@/api/client' +import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor' export default function ShareItemDialog({ controller }) { const { modal, setModal, action } = controller @@ -15,6 +16,7 @@ export default function ShareItemDialog({ controller }) { const [link, setLink] = useState('') const [error, setError] = useState('') const [busy, setBusy] = useState(false) + const [rights,setRights]=useState(PRESETS.reader) const reload = async (id = itemId) => { if (!id) return const data = await drive.itemShares(id) @@ -40,7 +42,7 @@ export default function ShareItemDialog({ controller }) { finally{setBusy(false)} } const addUser = user => perform(async()=>{ - await drive.shareItem(itemId,user.id) + await drive.shareItem(itemId,user.id,rights) setQuery('');setResults([]) }) const createLink = () => perform(async()=>{ @@ -57,6 +59,7 @@ export default function ShareItemDialog({ controller }) {

Partager avec un utilisateur

+ setQuery(e.target.value)}/> {results.length>0 &&
{results.map(user=>)}
} {record.users.map(user=>
- {[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username}) +
{[user.first_name,user.last_name].filter(Boolean).join(' ') || user.username} (@{user.username})perform(()=>drive.shareItemPermissions(itemId,user.id,permissions))}/>
)}
-
+ {action?.permissions?.includes('download') &&

Accès public

{link &&
} @@ -76,7 +79,7 @@ export default function ShareItemDialog({ controller }) { Lien créé le {new Date(publicLink.created_at).toLocaleDateString('fr-FR')}
)}
} - + } {error &&

{error}

} diff --git a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx index e63450c..487ae38 100644 --- a/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx +++ b/frontend/src/components/drive/dialogs/SpaceSettingsDialog.jsx @@ -11,7 +11,7 @@ export default function SpaceSettingsDialog({controller: c}) { const {spaceDialog, setSpaceDialog, spaces, activeSpaceId, loadSpaces, switchSpace, setError, setNote, setManagedSpaceId} = c const manage = spaceDialog?.mode === 'manage' const current = spaces.find(space => space.id === spaceDialog?.spaceId) - const managed = spaces.filter(space => !space.is_private && space.can_invite) + const managed = spaces.filter(space => !space.is_private && space.permissions?.includes('create_spaces')) const [name,setName] = useState('') const [quota,setQuota] = useState('') const [parent,setParent] = useState('') @@ -19,7 +19,7 @@ export default function SpaceSettingsDialog({controller: c}) { const [localError,setLocalError] = useState('') useEffect(() => { setName(manage ? current?.display_name || '' : '') - setQuota(manage ? fromQuota(current?.quota_bytes) : '') + setQuota(manage ? fromQuota(current?.quota_bytes) : fromQuota(managed.find(sp=>sp.id===(spaceDialog?.parentId || managed[0]?.id))?.quota_bytes)) setParent(String(manage ? current?.parent_id || '' : spaceDialog?.parentId || managed[0]?.id || '')) setLocalError('') }, [spaceDialog?.mode,spaceDialog?.spaceId,spaceDialog?.parentId,current?.display_name,current?.quota_bytes,current?.parent_id,managed[0]?.id]) @@ -52,7 +52,7 @@ export default function SpaceSettingsDialog({controller: c}) {
{!manage &&
- {setParent(e.target.value);setQuota(fromQuota(managed.find(sp=>sp.id===Number(e.target.value))?.quota_bytes))}} required className="h-10 w-full rounded-md border bg-background px-3 text-sm"> {managed.map(space=>)}
} diff --git a/frontend/src/hooks/useDriveController.js b/frontend/src/hooks/useDriveController.js index c838cf4..1c3334a 100644 --- a/frontend/src/hooks/useDriveController.js +++ b/frontend/src/hooks/useDriveController.js @@ -13,6 +13,7 @@ export function useDriveController(user) { const [parent, setParent] = useState(null) const [trail, setTrail] = useState([]) const [items, setItems] = useState([]) + const [sharedFolderAccess,setSharedFolderAccess]=useState({can_write:false,space_id:null,parentId:null}) const [stats, setStats] = useState(null) const [pendingUploads, setPendingUploads] = useState([]) const [query, setQuery] = useState('') @@ -53,7 +54,7 @@ export function useDriveController(user) { }, [query]) const activeSpace = spaces.find(s => s.id === activeSpaceId) - const canWrite = view === 'files' && Boolean(activeSpace?.can_write) + const canWrite = (view === 'files' && Boolean(activeSpace?.permissions?.includes('add'))) || (view==='shared' && Boolean(parent && sharedFolderAccess.parentId===parent && sharedFolderAccess.can_write)) const loadSpaces = useCallback(async () => { try { const data = await drive.spaces() @@ -89,6 +90,7 @@ export function useDriveController(user) { const data = await drive.list(params) if (sequence !== requestSequence.current) return setItems(data.items) + if(view==='shared')setSharedFolderAccess({can_write:data.can_write,space_id:data.space_id,parentId:parent}) setTrail(data.breadcrumb || []) if (data.limited && !silent) setNote('La liste affiche les 500 premiers résultats. Utilise la recherche pour affiner.') if (!silent) setError('') @@ -191,7 +193,7 @@ export function useDriveController(user) { const uploadFiles = incoming => { if (!canWrite || view === 'trash') return - uploads.addFiles(incoming, view === 'files' ? parent : null, activeSpaceId) + uploads.addFiles(incoming, parent, view === 'shared' ? sharedFolderAccess.space_id : activeSpaceId) if (filesInput.current) filesInput.current.value = '' } @@ -275,7 +277,7 @@ export function useDriveController(user) { const actionSubmit = event => { event.preventDefault() - if (modal === 'folder') return run(() => drive.folder(value, parent, activeSpaceId)) + if (modal === 'folder') return run(() => drive.folder(value,parent,view==='shared'?sharedFolderAccess.space_id:activeSpaceId)) if (modal === 'rename') return run(() => drive.patch(action.id, { name: value })) if (modal === 'move') return run(() => drive.patch(action.id, { parent: moveFolder })) } diff --git a/frontend/src/pages/MembersPage.jsx b/frontend/src/pages/MembersPage.jsx index 93ba1ba..b4c6679 100644 --- a/frontend/src/pages/MembersPage.jsx +++ b/frontend/src/pages/MembersPage.jsx @@ -3,28 +3,17 @@ import { Check, Copy, KeyRound, Link2, ShieldCheck, Trash2, Users } from 'lucide import { drive, message } from '@/api/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Checkbox } from '@/components/ui/checkbox' import QuotaField from '@/components/drive/QuotaField' import {fromQuota, toQuota} from '@/lib/quota-utils' import {fmtSize} from '@/lib/drive-format' - -const options = [ - { key: 'can_view_parent', label: 'Voir les fichiers du parent' }, - { key: 'can_write_parent', label: 'Modifier les fichiers du parent' }, - { key: 'can_invite', label: 'Créer des sous-utilisateurs' }, -] -function Permissions({ value, onChange, disabled = false, maxRights = {}, fixedRead = false }) { - return
{options.map(opt => )}
-} +import PermissionsEditor, {PRESETS} from '@/components/drive/PermissionsEditor' export default function MembersPage({ controller, user, embedded = false }) { const { spaces, activeSpaceId, loadSpaces, managedSpaceId, setManagedSpaceId, setSpaceDialog } = controller const [parentId, setParentId] = useState(managedSpaceId || user.space_id) const [record, setRecord] = useState(null) const reloadSequence = useRef(0) - const [rights, setRights] = useState({can_view_parent:true,can_write_parent:false,can_invite:false}) + const [rights, setRights] = useState(PRESETS.reader) const [url, setUrl] = useState('') const [title, setTitle] = useState('') const [busy, setBusy] = useState(false) @@ -36,7 +25,7 @@ export default function MembersPage({ controller, user, embedded = false }) { const [userResults, setUserResults] = useState([]) const [searching, setSearching] = useState(false) const [selectedAccount, setSelectedAccount] = useState(null) - const [directRights, setDirectRights] = useState({can_write:false,can_invite:false}) + const [directRights, setDirectRights] = useState(PRESETS.reader) const mine = spaces.find(s => s.id === user.space_id) const managed = spaces.filter(s => !s.is_private && s.can_invite && (!embedded || s.id === managedSpaceId)) @@ -74,7 +63,7 @@ export default function MembersPage({ controller, user, embedded = false }) { const create = async () => { setBusy(true);setError('');setUrl('') try { - const inv = await drive.invite({ ...rights, expires_days:Number(expires), parent_space:parentId }) + const inv = await drive.invite({ permissions:rights, expires_days:Number(expires), parent_space:parentId }) setUrl(`${window.location.origin}/invite/${inv.token}`) setInfo('Invitation créée ! Le lien n’est affiché qu’une seule fois : copie-le maintenant.') await reload() @@ -114,7 +103,7 @@ export default function MembersPage({ controller, user, embedded = false }) { if (!selectedAccount) return setBusy(true); setError('') try { - await drive.addUser(selectedAccount.id,parentId,directRights.can_write,directRights.can_invite) + await drive.addUser(selectedAccount.id,parentId,directRights) setInfo(`Accès accordé à ${selectedAccount.first_name} ${selectedAccount.last_name}`.trim()) setSelectedAccount(null);setUserQuery('');setUserResults([]) await reload();await loadSpaces() @@ -179,17 +168,16 @@ export default function MembersPage({ controller, user, embedded = false }) { } {selectedAccount &&

Utilisateur sélectionné : {[selectedAccount.first_name,selectedAccount.last_name].filter(Boolean).join(' ')} (@{selectedAccount.username})

- - +
} {!!directGrants.length &&

Utilisateurs ajoutés directement

- {directGrants.map(g=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)} + {directGrants.map(g=>updateGrant(g.id,data)} remove={()=>revokeGrant(g.id)}/>)}
}
Créer une invitation
- + @@ -209,9 +197,9 @@ export default function MembersPage({ controller, user, embedded = false }) { } function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) { - const [rights,setRights]=useState({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite}) + const [rights,setRights]=useState(child.parent_permissions || PRESETS.reader) const [quota,setQuota]=useState(fromQuota(child.quota_bytes)) - useEffect(()=>{setRights({can_view_parent:child.can_view_parent,can_write_parent:child.can_write_parent,can_invite:child.can_invite});setQuota(fromQuota(child.quota_bytes))},[child]) + useEffect(()=>{setRights(child.parent_permissions || PRESETS.reader);setQuota(fromQuota(child.quota_bytes))},[child]) if (!child.has_account) return
{child.display_name}
@@ -219,7 +207,7 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) {
{child.can_invite && }
- return
{child.display_name}
{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}
+ return
{child.display_name}
{[child.first_name,child.last_name].filter(Boolean).join(' ') || child.username} · @{child.username} · {child.is_active?'Actif':'Suspendu'}

Utilisé : {fmtSize(child.used_bytes || 0)}{child.reserved_bytes > 0 ? ` · Transferts réservés : ${fmtSize(child.reserved_bytes)}` : ''} · Disponible : {child.available_bytes == null ? 'Sans limite' : fmtSize(child.available_bytes)}

@@ -227,16 +215,12 @@ function MemberRow({child,save,busy,onManage,onInvalid,maxRights}) { function DirectMember({grant,save,remove,busy,maxRights}) { - const [canWrite,setCanWrite] = useState(grant.can_write) - const [canInvite,setCanInvite] = useState(grant.can_invite) - useEffect(()=>{setCanWrite(grant.can_write);setCanInvite(grant.can_invite)},[grant]) - return
-
{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}
@{grant.username}
-
- - - - -
+ const [permissions,setPermissions]=useState(grant.permissions || PRESETS.reader) + useEffect(()=>setPermissions(grant.permissions || PRESETS.reader),[grant]) + return
+

{[grant.first_name,grant.last_name].filter(Boolean).join(' ') || grant.username}

@{grant.username}

+
+ +
} diff --git a/frontend/src/pages/PublicSharePage.jsx b/frontend/src/pages/PublicSharePage.jsx index 2e7d730..9e591ad 100644 --- a/frontend/src/pages/PublicSharePage.jsx +++ b/frontend/src/pages/PublicSharePage.jsx @@ -1,41 +1,72 @@ import { useCallback, useEffect, useState } from 'react' -import { ArrowLeft, Download, File, Folder, LoaderCircle } from 'lucide-react' +import { ArrowLeft, Download, File, Folder, FolderOpen, LoaderCircle, Eye, X, Image, FileText, ShieldCheck } from 'lucide-react' import { Button } from '@/components/ui/button' import { drive, message, publicFileUrl } from '@/api/client' +import { fmtSize } from '@/lib/drive-format' +function typeOf(item) { + const n = (item.name || '').toLowerCase(), m=(item.mime_type || '').toLowerCase() + if(m.startsWith('image/') && m!=='image/svg+xml' || /\.(png|jpe?g|gif|webp|bmp|avif)$/.test(n)) return 'image' + if(m==='application/pdf' || n.endsWith('.pdf'))return 'pdf' + if(m.startsWith('video/') || /\.(mp4|webm|mov|m4v|ogv)$/.test(n))return 'video' + if(m.startsWith('audio/') || /\.(mp3|ogg|wav|m4a|flac|aac)$/.test(n))return 'audio' + if(m.startsWith('text/') || /\.(txt|md|json|csv|xml|log|py|js|css)$/.test(n))return 'text' + return null +} +function Viewer({item,token}) { + const kind=typeOf(item) + const src=publicFileUrl(token,item.id,true) + if(kind==='image')return {item.name} + if(kind==='pdf')return