From c11c6618979450817cf2820410e7f339fd348dae Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Wed, 14 Oct 2026 15:58:46 +0200 Subject: [PATCH] test --- backend/core/urls.py | 4 +- backend/drive/migrations/0015_item_shares.py | 23 ++ backend/drive/models.py | 20 ++ backend/drive/services.py | 11 +- backend/drive/spaces.py | 24 +- backend/drive/test_item_sharing.py | 119 ++++++++ backend/drive/test_private_layout.py | 272 ++++++++++++++++++ backend/drive/urls.py | 8 +- backend/drive/views_items.py | 29 +- backend/drive/views_shares.py | 148 ++++++++++ backend/drive/views_spaces.py | 76 +++-- frontend/src/api/client.js | 10 + .../src/components/drive/ContextActions.jsx | 5 +- .../src/components/drive/DriveDialogs.jsx | 2 + .../src/components/drive/DriveExplorer.jsx | 14 +- .../src/components/drive/DriveSidebar.jsx | 4 +- .../drive/dialogs/ItemActionsDialog.jsx | 5 +- .../drive/dialogs/ShareItemDialog.jsx | 84 ++++++ frontend/src/config/navigation.js | 2 +- frontend/src/hooks/useDriveController.js | 11 +- frontend/src/pages/AdminUsersPage.jsx | 38 +++ frontend/src/pages/DrivePage.jsx | 9 +- frontend/src/pages/InvitationPage.jsx | 8 +- frontend/src/pages/MembersPage.jsx | 26 +- frontend/src/pages/PublicSharePage.jsx | 41 +++ 25 files changed, 917 insertions(+), 76 deletions(-) create mode 100644 backend/drive/migrations/0015_item_shares.py create mode 100644 backend/drive/test_item_sharing.py create mode 100644 backend/drive/test_private_layout.py create mode 100644 backend/drive/views_shares.py create mode 100644 frontend/src/components/drive/dialogs/ShareItemDialog.jsx create mode 100644 frontend/src/pages/AdminUsersPage.jsx create mode 100644 frontend/src/pages/PublicSharePage.jsx diff --git a/backend/core/urls.py b/backend/core/urls.py index 9259728..96213c1 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -2,8 +2,10 @@ from django.contrib import admin from django.urls import path, include from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from drive.views import me -from drive import views_spaces +from drive import views_spaces, views_shares urlpatterns = [ + path('api/public//',views_shares.public_share), + path('api/public//files//',views_shares.public_file), path('api/invitations//',views_spaces.invitation_preview), path('api/invitations//accept/',views_spaces.accept_invitation), path('django-admin/', admin.site.urls), diff --git a/backend/drive/migrations/0015_item_shares.py b/backend/drive/migrations/0015_item_shares.py new file mode 100644 index 0000000..e3da9f9 --- /dev/null +++ b/backend/drive/migrations/0015_item_shares.py @@ -0,0 +1,23 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +class Migration(migrations.Migration): + dependencies = [('drive','0014_private_quota_within_assigned'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel(name='ItemGrant', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_shares', to='drive.driveitem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shared_drive_items', to=settings.AUTH_USER_MODEL)), + ], options={'constraints':[models.UniqueConstraint(fields=('item','user'), name='drive_item_user_share_unique')]}), + migrations.CreateModel(name='PublicItemLink', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.AUTH_USER_MODEL)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='public_links', to='drive.driveitem')), + ]), + ] diff --git a/backend/drive/models.py b/backend/drive/models.py index 141907d..89e7603 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -102,3 +102,23 @@ class UploadSession(models.Model): received = models.BigIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + + +class ItemGrant(models.Model): + """Read-only item/folder access, independent of membership in its space.""" + 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='+') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [models.UniqueConstraint(fields=['item', 'user'], name='drive_item_user_share_unique')] + + +class PublicItemLink(models.Model): + """Secret-token link to a single file or directory tree.""" + item = models.ForeignKey(DriveItem, on_delete=models.CASCADE, related_name='public_links') + token_hash = models.CharField(max_length=64, unique=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+') + created_at = models.DateTimeField(auto_now_add=True) + revoked_at = models.DateTimeField(null=True, blank=True) diff --git a/backend/drive/services.py b/backend/drive/services.py index 97551fc..498499c 100644 --- a/backend/drive/services.py +++ b/backend/drive/services.py @@ -3,7 +3,7 @@ import uuid from django.core.exceptions import ValidationError from django.http import Http404 from rest_framework.response import Response -from .models import DriveItem +from .models import DriveItem, ItemGrant from .spaces import can_read_space, can_write_space @@ -16,12 +16,12 @@ def item_info(item, user=None): 'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder, 'size':item.size,'mime_type':item.mime_type,'owner_id':item.owner_id, 'owner':item.owner.username,'parent':str(item.parent_id) if item.parent_id else None, - 'space_id':item.space_id, 'is_shared':False, + 'space_id':item.space_id, 'is_shared':item.user_shares.exists() or any(link.revoked_at is None for link in item.public_links.all()), 'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None, 'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(), } if user is not None: - data['can_write'] = can_write_space(user, item.space) + data['can_write'] = writable(item,user) return data @@ -45,7 +45,10 @@ def chain(item): def readable(item, user, allow_trashed=False): - if not can_read_space(user,item.space): + 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: return False if not allow_trashed and any(n.trashed_at is not None for n in chain(item)): return False diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py index cb90244..a5c6023 100644 --- a/backend/drive/spaces.py +++ b/backend/drive/spaces.py @@ -14,11 +14,16 @@ def is_admin(user): def ensure_space(user): - """One shared/assigned space for every user; the administrator owns root.""" + """Return an owned space, or the existing space granted to an invitee.""" obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first() if obj: (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) return obj + # Invitation-only users access an existing space through a SpaceGrant. + # They must never create another numbered shared directory implicitly. + grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at','pk').first() + if grant: + return grant.space with transaction.atomic(): obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first() if obj: @@ -39,8 +44,9 @@ def _make_numbered_space(parent, **fields): private = fields.get('private_owner') is not None if parent is None or parent.private_owner_id: raise ValueError('Un espace doit avoir un parent partagé.') - if private and parent.user_id != fields['private_owner'].pk: - raise ValueError('L’espace privé doit être rattaché au quota attribué à son propriétaire.') + if private and parent.user_id != fields['private_owner'].pk and not SpaceGrant.objects.filter( + user=fields['private_owner'], space=parent).exists(): + raise ValueError('L’espace privé doit être rattaché à un espace accessible par son propriétaire.') base = 'privates' if private else parent.relative_path if len(base) + 1 + 24 > 700: raise ValueError('Hiérarchie trop profonde.') @@ -122,9 +128,9 @@ def can_read_space(user, space): if is_admin(user) or space.managed_by_id == user.pk: return True own = ensure_space(user) - if own.pk == space.pk: + if own.pk == space.pk and own.user_id == user.pk: return True - if own.parent_id == space.pk and own.can_view_parent: + if 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 @@ -138,9 +144,9 @@ def can_write_space(user, space): if is_admin(user) or space.managed_by_id == user.pk: return True own = ensure_space(user) - if own.pk == space.pk: + if own.pk == space.pk and own.user_id == user.pk: return True - if own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: + if 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) @@ -180,7 +186,9 @@ def space_info(space, user): 'parent_id': space.parent_id, 'display_name': space.display_name, '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), 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, + 'can_invite': can_manage_space(user, space), + 'can_edit': is_admin(user) or space.managed_by_id == user.pk or (space.user_id == user.pk and can_manage_space(user, space)), + '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, 'created_at': space.created_at.isoformat(), diff --git a/backend/drive/test_item_sharing.py b/backend/drive/test_item_sharing.py new file mode 100644 index 0000000..1b5cd0a --- /dev/null +++ b/backend/drive/test_item_sharing.py @@ -0,0 +1,119 @@ +"""New invitation and file-sharing semantics. + +Run: USE_SQLITE=true python manage.py test drive.test_item_sharing +""" +import tempfile +import uuid +from pathlib import Path +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from rest_framework.test import APIClient +from .models import DriveItem, DriveSpace, SpaceGrant +from .spaces import ensure_space, ensure_private_space + + +class SharesTests(TestCase): + def setUp(self): + tmp=tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.storage=Path(tmp.name) + overrides=override_settings(DRIVE_STORAGE_ROOT=self.storage, + FILE_UPLOAD_TEMP_DIR=str(self.storage/'.uploads')) + overrides.enable() + self.addCleanup(overrides.disable) + User=get_user_model() + self.admin=User.objects.create_superuser('admin-shares','admin@example.org','ComplexPassword123!') + self.root=ensure_space(self.admin) + ensure_private_space(self.admin) + self.client=APIClient() + self.client.force_authenticate(user=self.admin) + + def invite_user(self, name, **rights): + self.client.force_authenticate(user=self.admin) + created=self.client.post('/api/drive/invitations/',{'parent_space':self.root.pk,**rights},format='json') + self.assertEqual(created.status_code,201,created.data) + client=APIClient() + accepted=client.post(f"/api/invitations/{created.data['token']}/accept/", + {'username':name,'password':'ComplexPassword123!','first_name':'Test','last_name':name},format='json') + self.assertEqual(accepted.status_code,201,accepted.data) + return get_user_model().objects.get(username=name) + + def test_new_invitee_uses_existing_space_without_new_shared_directory(self): + alice=self.invite_user('alice-share',can_write_parent=False,can_invite=True) + self.assertFalse(DriveSpace.objects.filter(user=alice).exists()) + self.assertTrue(SpaceGrant.objects.filter(user=alice,space=self.root,can_invite=True,can_write=False).exists()) + self.assertEqual(ensure_space(alice).pk,self.root.pk) + self.assertEqual(ensure_private_space(alice).parent_id,self.root.pk) + self.client.force_authenticate(user=alice) + self.assertEqual(self.client.post('/api/drive/folders/',{'space':self.root.pk,'name':'not-writable'},format='json').status_code,403) + too_much=self.client.post('/api/drive/invitations/',{ + 'parent_space':self.root.pk,'can_view_parent':True,'can_write_parent':True},format='json') + self.assertEqual(too_much.status_code,403) + allowed=self.client.post('/api/drive/invitations/',{ + 'parent_space':self.root.pk,'can_view_parent':True,'can_invite':True},format='json') + self.assertEqual(allowed.status_code,201,allowed.data) + # Creating a subspace is an explicit operation for users with can_invite. + sub=self.client.post('/api/drive/spaces/create/',{ + 'parent_space':self.root.pk,'display_name':'Equipe','quota_bytes':None},format='json') + self.assertEqual(sub.status_code,201,sub.data) + + def test_private_item_share_is_read_only_and_revocable(self): + alice=self.invite_user('alice-files') + bob=self.invite_user('bob-files') + private=ensure_private_space(alice) + folder=DriveItem.objects.create(owner=alice,space=private,name='Documents',is_folder=True) + child=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='photo.jpg', + storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Documents/photo.jpg',size=3) + secret=DriveItem.objects.create(owner=alice,space=private,name='secret.jpg', + storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/secret.jpg',size=3) + self.client.force_authenticate(user=alice) + shared=self.client.post(f'/api/drive/items/{folder.pk}/shares/',{'user_id':bob.pk},format='json') + self.assertEqual(shared.status_code,201,shared.data) + self.client.force_authenticate(user=bob) + listing=self.client.get('/api/drive/items/',{'view':'shared'}) + self.assertEqual(listing.status_code,200,listing.data) + self.assertEqual([item['name'] for item in listing.data['items']],['Documents']) + children=self.client.get('/api/drive/items/',{'view':'shared','parent':str(folder.pk)}) + self.assertEqual(children.status_code,200,children.data) + self.assertEqual(children.data['items'][0]['id'],str(child.pk)) + self.assertFalse(children.data['items'][0]['can_write']) + self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,200) + self.assertEqual(self.client.post(f'/api/drive/items/{secret.pk}/link/').status_code,403) + self.assertEqual(self.client.patch(f'/api/drive/items/{child.pk}/',{'name':'change.jpg'},format='json').status_code,403) + self.client.force_authenticate(user=alice) + self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/shares/{shared.data["id"]}/').status_code,204) + self.client.force_authenticate(user=bob) + self.assertEqual(self.client.post(f'/api/drive/items/{child.pk}/link/').status_code,403) + + def test_public_link_access_and_revocation(self): + alice=self.invite_user('alice-public') + private=ensure_private_space(alice) + folder=DriveItem.objects.create(owner=alice,space=private,name='Photos',is_folder=True) + image=DriveItem.objects.create(owner=alice,space=private,parent=folder,name='pic.png', + storage_key=str(uuid.uuid4()),storage_location=f'{private.relative_path}/Photos/pic.png',size=3) + path=self.storage/image.storage_location + path.parent.mkdir(parents=True,exist_ok=True) + path.write_bytes(b'PNG') + self.client.force_authenticate(user=alice) + public=self.client.post(f'/api/drive/items/{folder.pk}/public-links/',{},format='json') + self.assertEqual(public.status_code,201,public.data) + token=public.data['token'] + anonymous=APIClient() + res=anonymous.get(f'/api/public/{token}/') + self.assertEqual(res.status_code,200,res.data) + self.assertEqual(res.data['items'][0]['name'],'pic.png') + content=anonymous.get(f'/api/public/{token}/files/{image.pk}/') + self.assertEqual(content.status_code,200) + self.assertEqual(b''.join(content.streaming_content),b'PNG') + links=self.client.get(f'/api/drive/items/{folder.pk}/shares/').data['links'] + self.assertEqual(self.client.delete(f'/api/drive/items/{folder.pk}/public-links/{links[0]["id"]}/').status_code,204) + self.assertEqual(anonymous.get(f'/api/public/{token}/').status_code,404) + + def test_admin_user_list_is_private_to_admin(self): + alice=self.invite_user('alice-admin-list') + self.client.force_authenticate(user=alice) + self.assertEqual(self.client.get('/api/drive/admin/users/').status_code,403) + self.client.force_authenticate(user=self.admin) + users=self.client.get('/api/drive/admin/users/',{'q':'alice-admin-list'}) + self.assertEqual(users.status_code,200,users.data) + self.assertEqual(users.data['users'][0]['space_name'],self.root.display_name) diff --git a/backend/drive/test_private_layout.py b/backend/drive/test_private_layout.py new file mode 100644 index 0000000..6925995 --- /dev/null +++ b/backend/drive/test_private_layout.py @@ -0,0 +1,272 @@ +"""Private disk layout, 50-Go private cap, and real-byte pooled accounting. + +Run with: USE_SQLITE=true python manage.py test drive.test_private_layout +""" +import tempfile +import uuid +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import transaction +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from .models import DriveItem, DriveSpace, SpaceInvitation, SpaceGrant +from .quotas import (PRIVATE_SPACE_QUOTA_BYTES, QuotaExceeded, assert_space_capacity, + assert_updated_quota, locked_quota_tree, occupancy, quota_state) +from .spaces import ensure_private_space, ensure_space, create_space, storage_path + + +class PrivateStorageTests(TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.rootdir = Path(self.temp.name) + overrides = override_settings( + DRIVE_STORAGE_ROOT=self.rootdir, + FILE_UPLOAD_TEMP_DIR=str(self.rootdir / '.uploads'), + ) + overrides.enable() + self.addCleanup(overrides.disable) + User = get_user_model() + self.admin = User.objects.create_superuser('root-admin', 'admin@example.org', 'TestPassword123!') + self.shared = ensure_space(self.admin) + self.private = ensure_private_space(self.admin) + self.client = APIClient() + self.client.force_authenticate(user=self.admin) + + def test_private_directories_are_separate_from_shared_spaces(self): + self.assertEqual(self.private.parent_id, self.shared.pk) + self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') + self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) + self.assertTrue((self.rootdir / self.private.relative_path).is_dir()) + person = get_user_model().objects.create_user('member', password='TestPassword123!') + shared = create_space(person, self.shared, 'Compte membre') + private = ensure_private_space(person) + self.assertTrue(shared.relative_path.startswith(f'{shared.directory_name}')) + self.assertEqual(private.relative_path, f'privates/{private.directory_name}') + self.assertEqual(private.parent_id, shared.pk) + self.assertNotEqual(shared.directory_name, private.directory_name) + + def test_private_usage_counts_toward_assigned_space_without_reserving(self): + self.shared.quota_bytes = 100 + self.shared.save(update_fields=['quota_bytes']) + # Private can be 50 Go even when the assigned budget is just 100 bytes. + self.assertEqual(quota_state(self.shared)['available_bytes'], 100) + self.assertEqual(quota_state(self.private)['available_bytes'], 100) + created = self.client.post('/api/drive/uploads/', { + 'files': [SimpleUploadedFile('photo.jpg', b'abcdef')] + }, format='multipart') + self.assertEqual(created.status_code, 201, created.data) + node = DriveItem.objects.get(pk=created.data['items'][0]['id']) + self.assertEqual(node.storage_location, f'{self.private.relative_path}/photo.jpg') + self.assertEqual(storage_path(node).read_bytes(), b'abcdef') + self.assertEqual(occupancy(self.shared)[0], 6) + self.assertEqual(quota_state(self.shared)['available_bytes'], 94) + self.assertEqual(quota_state(self.private)['available_bytes'], 94) + self.assertEqual(quota_state(self.private)['quota_bytes'], PRIVATE_SPACE_QUOTA_BYTES) + self.assertEqual(quota_state(self.private)['used_bytes'], 6) + rejected = self.client.patch(f'/api/drive/spaces/{self.private.pk}/quota/', { + 'quota_bytes': None + }, format='json') + self.assertEqual(rejected.status_code, 403) + + def test_actual_private_use_is_pooled_in_100_go_assigned_space(self): + person = get_user_model().objects.create_user('quota-user', password='TestPassword123!') + assigned = create_space(person, self.shared, '100 Go', quota_bytes=100 * 1024 ** 3) + private = ensure_private_space(person) + gib = 1024 ** 3 + self.assertEqual(private.parent_id, assigned.pk) + self.assertEqual(private.relative_path, f'privates/{private.directory_name}') + self.assertEqual(quota_state(assigned)['available_bytes'], 100 * gib) + self.assertEqual(quota_state(private)['available_bytes'], 50 * gib) + # Existing 20 Go in shared space. This leaves both limits at their max. + DriveItem.objects.create( + owner=person, space=assigned, name='shared.bin', + storage_key=str(uuid.uuid4()), storage_location=f'{assigned.relative_path}/shared.bin', + size=20 * gib, + ) + self.assertEqual(quota_state(assigned)['used_bytes'], 20 * gib) + self.assertEqual(quota_state(private)['used_bytes'], 0) + DriveItem.objects.create( + owner=person, space=private, name='private.bin', + storage_key=str(uuid.uuid4()), storage_location=f'{private.relative_path}/private.bin', + size=20 * gib, + ) + self.assertEqual(occupancy(assigned)[0], 40 * gib) + self.assertEqual(quota_state(assigned)['used_bytes'], 40 * gib) + self.assertEqual(quota_state(assigned)['available_bytes'], 60 * gib) + self.assertEqual(quota_state(private)['used_bytes'], 20 * gib) + self.assertEqual(quota_state(private)['available_bytes'], 30 * gib) + with transaction.atomic(): + with locked_quota_tree(assigned): + assert_space_capacity(assigned, 60 * gib) + with self.assertRaises(QuotaExceeded): + assert_space_capacity(assigned, 60 * gib + 1) + assert_updated_quota(assigned, 40 * gib) + with self.assertRaisesRegex(ValueError, 'fichiers'): + assert_updated_quota(assigned, 39 * gib) + + def test_parent_limit_blocks_private_even_before_50_go(self): + self.shared.quota_bytes = 20 + self.shared.save(update_fields=['quota_bytes']) + DriveItem.objects.create( + owner=self.admin, space=self.private, name='existing.bin', + storage_key=str(uuid.uuid4()), storage_location=f'{self.private.relative_path}/existing.bin', size=19, + ) + self.assertEqual(quota_state(self.private)['available_bytes'], 1) + with transaction.atomic(): + with locked_quota_tree(self.private): + assert_space_capacity(self.private, 1) + with self.assertRaises(QuotaExceeded): + assert_space_capacity(self.private, 2) + + def test_invitation_preview_includes_creator_full_name_and_parent_space(self): + self.admin.first_name = 'Camille' + self.admin.last_name = 'Martin' + self.admin.save(update_fields=['first_name', 'last_name']) + result = self.client.post('/api/drive/invitations/', { + 'parent_space': self.shared.pk, + }, format='json') + self.assertEqual(result.status_code, 201, result.data) + preview = APIClient().get(f"/api/invitations/{result.data['token']}/") + self.assertEqual(preview.status_code, 200, preview.data) + self.assertEqual(preview.data['inviter_name'], 'Camille Martin') + self.assertEqual(preview.data['parent_name'], self.shared.display_name) + + self.admin.first_name = '' + self.admin.last_name = '' + self.admin.save(update_fields=['first_name', 'last_name']) + preview = APIClient().get(f"/api/invitations/{result.data['token']}/") + self.assertEqual(preview.status_code, 200, preview.data) + self.assertEqual(preview.data['inviter_name'], self.admin.username) + + def test_invitation_has_no_quota_even_from_an_old_client(self): + self.shared.quota_bytes = 15 + self.shared.save(update_fields=['quota_bytes']) + # Even an old client cannot allocate storage through an invitation. + result = self.client.post('/api/drive/invitations/', { + 'parent_space': self.shared.pk, 'quota_bytes': 10, + }, format='json') + self.assertEqual(result.status_code, 201, result.data) + self.assertNotIn('quota_bytes', result.data) + invitation = SpaceInvitation.objects.get(pk=result.data['id']) + self.assertIsNone(invitation.quota_bytes) + self.assertEqual(quota_state(self.shared)['available_bytes'], 15) + + # Legacy unredeemed invitations may still contain a saved quota. + # The quota must not be applied when the invite is accepted. + invitation.quota_bytes = 1 + invitation.save(update_fields=['quota_bytes']) + preview = APIClient().get(f"/api/invitations/{result.data['token']}/") + self.assertEqual(preview.status_code, 200, preview.data) + self.assertNotIn('quota_bytes', preview.data) + accepted = APIClient().post( + f"/api/invitations/{result.data['token']}/accept/", { + 'username': 'invitee', 'password': 'TestPassword123!', + 'first_name': 'Jean', 'last_name': 'Dupont', + 'display_name': 'Dossier invité', + }, format='json', + ) + self.assertEqual(accepted.status_code, 201, accepted.data) + user = get_user_model().objects.get(username='invitee') + self.assertFalse(DriveSpace.objects.filter(user=user).exists()) + self.assertTrue(SpaceGrant.objects.filter(user=user, space=self.shared).exists()) + assigned = ensure_space(user) + private = ensure_private_space(user) + self.assertEqual(assigned.pk, self.shared.pk) + self.assertEqual(private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) + self.assertEqual(private.parent_id, self.shared.pk) + self.assertEqual(quota_state(assigned)['available_bytes'], 15) + self.assertEqual(quota_state(private)['available_bytes'], 15) + + def test_private_pending_uploads_count_in_parent(self): + from .models import UploadSession + UploadSession.objects.create( + owner=self.admin, space=self.private, name='movie.bin', total_size=1234 + ) + self.assertEqual(quota_state(self.shared)['reserved_bytes'], 1234) + self.assertEqual(quota_state(self.shared)['used_bytes'], 0) + self.assertEqual(quota_state(self.private)['available_bytes'], PRIVATE_SPACE_QUOTA_BYTES - 1234) + + def test_private_50_go_upload_limit_is_enforced(self): + DriveItem.objects.create( + owner=self.admin, space=self.private, name='large.bin', + storage_key=str(uuid.uuid4()), + storage_location=f'{self.private.relative_path}/large.bin', + size=PRIVATE_SPACE_QUOTA_BYTES - 1, + ) + with transaction.atomic(): + with locked_quota_tree(self.private): + with self.assertRaises(QuotaExceeded): + assert_space_capacity(self.private, 2) + assert_space_capacity(self.private, 1) + + def simulate_legacy_private(self): + """Relocate the empty modern directory to the historical nested location.""" + new = self.rootdir / self.private.relative_path + new.rmdir() + old_name = self.private.directory_name + old = f'{old_name}' + src = self.rootdir / old + (src / 'Documents').mkdir(parents=True) + (src / 'Documents' / 'facture.pdf').write_bytes(b'PDF CONTENT') + self.private.parent = self.shared + self.private.relative_path = old + self.private.quota_bytes = None + self.private.save(update_fields=['parent', 'relative_path', 'quota_bytes']) + folder = DriveItem.objects.create( + owner=self.admin, space=self.private, name='Documents', is_folder=True, + ) + item = DriveItem.objects.create( + owner=self.admin, space=self.private, parent=folder, name='facture.pdf', + storage_key=str(uuid.uuid4()), storage_location=f'{old}/Documents/facture.pdf', + size=11, + ) + return old, item + + def test_migrate_private_storage_dry_run_and_apply(self): + old, item = self.simulate_legacy_private() + call_command('move_private_storage') + self.private.refresh_from_db() + self.assertEqual(self.private.relative_path, old) + self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) + call_command('move_private_storage', '--apply') + self.private.refresh_from_db() + item.refresh_from_db() + self.assertEqual(self.private.relative_path, f'privates/{self.private.directory_name}') + self.assertEqual(self.private.parent_id, self.shared.pk) + self.assertEqual(self.private.quota_bytes, PRIVATE_SPACE_QUOTA_BYTES) + self.assertEqual(item.storage_location, f'{self.private.relative_path}/Documents/facture.pdf') + self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') + self.assertFalse((self.rootdir / old).exists()) + self.assertFalse((self.rootdir / '.gooddrive-move-privates.json').exists()) + call_command('move_private_storage', '--apply') # Idempotent. + + def test_interrupted_migration_can_resume_after_directory_move(self): + from .management.commands.move_private_storage import Command, write_journal + old, item = self.simulate_legacy_private() + plan = Command().plan() + journal = self.rootdir / '.gooddrive-move-privates.json' + write_journal(journal, plan) + new = self.rootdir / 'privates' / self.private.directory_name + (self.rootdir / old).rename(new) # Simulate a crash before SQL update. + call_command('move_private_storage', '--apply') + item.refresh_from_db() + self.assertEqual(storage_path(item).read_bytes(), b'PDF CONTENT') + self.assertFalse(journal.exists()) + + def test_migration_never_overwrites_existing_destination(self): + old, item = self.simulate_legacy_private() + target = self.rootdir / 'privates' / self.private.directory_name + target.mkdir(parents=True) + (target / 'unrelated.txt').write_text('KEEP ME') + with self.assertRaises(CommandError): + call_command('move_private_storage', '--apply') + self.assertEqual((target / 'unrelated.txt').read_text(), 'KEEP ME') + self.assertTrue((self.rootdir / old / 'Documents' / 'facture.pdf').exists()) + item.refresh_from_db() + self.assertEqual(item.storage_location, f'{old}/Documents/facture.pdf') diff --git a/backend/drive/urls.py b/backend/drive/urls.py index fd4a93b..f0be772 100644 --- a/backend/drive/urls.py +++ b/backend/drive/urls.py @@ -1,8 +1,14 @@ from django.urls import path from . import views -from . import views_spaces +from . import views_spaces, views_shares urlpatterns=[ path('spaces/',views_spaces.spaces), + path('admin/users/',views_spaces.admin_users), + 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//public-links/',views_shares.public_links), + path('items//public-links//',views_shares.revoke_public_link), path('spaces/create/',views_spaces.create_shared_space), path('spaces//',views_spaces.edit_shared_space), path('spaces/me/',views_spaces.rename_my_space), diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index ad8e304..3190463 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -7,7 +7,7 @@ from django.db.models import Sum, Count from django.utils import timezone from rest_framework.decorators import api_view from rest_framework.response import Response -from .models import DriveItem, DriveSpace +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 .quotas import quota_state from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files @@ -28,7 +28,7 @@ def me(request): def listing(request): user=request.user view=request.query_params.get('view','files') - if view not in ('files','trash'): + if view not in ('files','trash','shared'): return error('Vue inconnue.',400) q=request.query_params.get('q','').strip() parent_id=request.query_params.get('parent') @@ -37,9 +37,20 @@ def listing(request): # In particular another person's private space is never included, even for admin. if view=='trash': accessible=[space.pk for space in DriveSpace.objects.all() if can_read_space(user,space)] - nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space') + nodes=DriveItem.objects.filter(space_id__in=accessible,trashed_at__isnull=False).select_related('owner','parent','space').prefetch_related('user_shares','public_links') if q:nodes=nodes.filter(name__icontains=q) space=None + elif view=='shared': + space=None + if parent_id: + parent=get_item(parent_id) + if not parent.is_folder or not readable(parent,user): + return error('Dossier partagé inaccessible.',403) + # Without inherited item permission or normal space access, browsing is forbidden. + nodes=DriveItem.objects.filter(parent=parent,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links') + else: + nodes=DriveItem.objects.filter(user_shares__user=user,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links').distinct() + if q:nodes=nodes.filter(name__icontains=q) else: try:space=resolve_space(user,request.query_params.get('space')) except PermissionError as ex:return error(str(ex),403) @@ -47,7 +58,7 @@ def listing(request): parent=get_item(parent_id) if not parent.is_folder or parent.space_id!=space.pk or not readable(parent,user): return error('Dossier inaccessible.',403) - nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space') + nodes=DriveItem.objects.filter(space=space,trashed_at__isnull=True).select_related('owner','parent','space').prefetch_related('user_shares','public_links') if q: nodes=nodes.filter(name__icontains=q) elif parent: @@ -58,12 +69,18 @@ def listing(request): for n in nodes[:501]: if readable(n,user,allow_trashed=view=='trash'): info=item_info(n,user) - if view=='trash':info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers' + if view in ('trash','shared'):info['space_name']=n.space.display_name if not n.space.private_owner_id else 'Mes fichiers' items.append(info) if len(items)>=500:break trail=[] if parent: - for n in reversed(chain(parent)): + ancestors=list(reversed(chain(parent))) + if view=='shared' and not can_read_space(user,parent.space): + shared_ancestors=set(ItemGrant.objects.filter(user=user,item_id__in=[a.pk for a in ancestors]).values_list('item_id',flat=True)) + for index,a in enumerate(ancestors): + if a.pk in shared_ancestors: + ancestors=ancestors[index:];break + 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}) diff --git a/backend/drive/views_shares.py b/backend/drive/views_shares.py new file mode 100644 index 0000000..17ccd0b --- /dev/null +++ b/backend/drive/views_shares.py @@ -0,0 +1,148 @@ +"""Read-only sharing of individual items and folder descendants. + +User shares grant no space-level privileges; public links use cryptographically +random bearer tokens and keep only SHA-256 digests in the database. +""" +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.db.models import Q +from django.utils import timezone +from rest_framework.decorators import api_view, permission_classes, throttle_classes +from rest_framework.permissions import AllowAny +from rest_framework.throttling import AnonRateThrottle +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 + + +def shareable(item, user): + return readable(item, user) and writable(item, user) + + +def share_info(grant): + u=grant.user + return {'id': grant.pk,'user_id':u.pk,'username':u.username, + 'first_name':u.first_name,'last_name':u.last_name} + + +@api_view(['GET']) +def search_share_users(request): + q=str(request.query_params.get('q','')).strip()[:100] + if len(q)<2:return Response({'users':[]}) + User=get_user_model() + users=User.objects.filter(is_active=True).exclude(pk=request.user.pk).filter( + Q(username__icontains=q) | + Q(first_name__icontains=q) | + Q(last_name__icontains=q) + ).order_by('username')[:15] + return Response({'users':[{'id':u.pk,'username':u.username, + 'first_name':u.first_name,'last_name':u.last_name} for u in users]}) + + +@api_view(['GET','POST']) +def manage_item_shares(request,item_id): + item=get_item(item_id) + if not shareable(item,request.user):return error('Partage non autorisé.',403) + if request.method=='GET': + grants=ItemGrant.objects.filter(item=item).select_related('user').order_by('user__username') + links=PublicItemLink.objects.filter(item=item,revoked_at__isnull=True).order_by('-created_at') + return Response({'users':[share_info(g) for g in grants], + 'links':[{'id':l.pk,'created_at':l.created_at.isoformat()} for l in links]}) + if item.trashed_at:return error('Impossible de partager un élément supprimé.',400) + try:user_id=int(request.data.get('user_id')) + except (TypeError,ValueError):return error('Utilisateur invalide.') + 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}) + 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(['DELETE']) +def remove_item_share(request,item_id,grant_id): + item=get_item(item_id) + if not shareable(item,request.user):return error('Partage non autorisé.',403) + deleted,_=ItemGrant.objects.filter(pk=grant_id,item=item).delete() + if not deleted:return error('Partage introuvable.',404) + return Response(status=204) + + +@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): + 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) + return Response({'token':token},status=201) + + +@api_view(['DELETE']) +def revoke_public_link(request,item_id,link_id): + item=get_item(item_id) + if not shareable(item,request.user):return error('Partage non autorisé.',403) + changed=PublicItemLink.objects.filter(pk=link_id,item=item,revoked_at__isnull=True).update(revoked_at=timezone.now()) + if not changed:return error('Lien introuvable.',404) + return Response(status=204) + + +def get_public_root(token): + if not token or len(token)>128:raise Http404 + digest=hashlib.sha256(token.encode()).hexdigest() + try:link=PublicItemLink.objects.select_related('item','item__parent','item__space').get( + token_hash=digest,revoked_at__isnull=True) + except PublicItemLink.DoesNotExist:raise Http404 + if any(n.trashed_at for n in chain(link.item)):raise Http404 + return link.item + + +def in_public_tree(root, candidate): + if candidate.space_id!=root.space_id:return False + return any(ancestor.pk==root.pk for ancestor in chain(candidate)) and not any(n.trashed_at for n in chain(candidate)) + + +@api_view(['GET']) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def public_share(request,token): + root=get_public_root(token) + parent_id=request.query_params.get('parent') + if parent_id: + try:folder=get_item(parent_id) + except (Http404,ValueError,ValidationError):raise Http404 + 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':[]}) + 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] + # Never list ancestors outside the shared root. + breadcrumb=[] + for node in reversed(chain(folder)): + if node.pk==root.pk or breadcrumb: + breadcrumb.append({'id':str(node.pk),'name':node.name}) + return Response({'root':{'id':str(root.pk),'name':root.name,'is_folder':True}, + 'breadcrumb':breadcrumb,'items':items[:500],'limited':len(items)>500}) + + +@api_view(['GET','HEAD']) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def public_file(request,token,item_id): + 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') + response['X-Content-Type-Options']='nosniff' + response['Cache-Control']='no-store' + response['Referrer-Policy']='no-referrer' + return response diff --git a/backend/drive/views_spaces.py b/backend/drive/views_spaces.py index e52a021..3a535e6 100644 --- a/backend/drive/views_spaces.py +++ b/backend/drive/views_spaces.py @@ -17,7 +17,7 @@ from rest_framework.throttling import AnonRateThrottle from rest_framework.response import Response from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession from .services import error, valid_name -from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_manage_space +from .spaces import ensure_space, ensure_private_space, my_space, create_space, _make_numbered_space, is_admin, hash_token, space_info, can_read_space, can_write_space, can_manage_space from .quotas import (parse_quota, locked_quota_tree, assert_new_child_quota, assert_updated_quota) @@ -27,10 +27,20 @@ def permission_flags(data): def manager(user, parent): - # Administrators can manage any descendant; delegates their own direct children only. return can_manage_space(user, parent) +def enforce_delegation(user, space, *, view=True, write=False, invite=False): + # These checks must be repeated on the server for each creation/change. + if view and not can_read_space(user, space): + raise ValueError('Tu ne peux pas attribuer la lecture de cet espace.') + if write and not can_write_space(user, space): + raise ValueError('Tu ne peux pas attribuer l’écriture de cet espace.') + if invite and not can_manage_space(user, space): + raise ValueError('Tu ne peux pas déléguer la gestion des utilisateurs.') + + + def invitation_info(inv): return {'id':str(inv.pk), 'created_at':inv.created_at.isoformat(), 'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at), @@ -48,7 +58,7 @@ def spaces(request): permitted=[private, *qs.filter(private_owner__isnull=True)] else: permitted=[private,own] - if own.parent_id and own.can_view_parent and not own.parent.private_owner_id: + if own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id: permitted.append(own.parent) permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id) permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True)) @@ -60,6 +70,8 @@ def spaces(request): @api_view(['PATCH']) def rename_my_space(request): own=my_space(request.user) + if own.user_id != request.user.pk and not is_admin(request.user): + return error('Impossible de renommer un espace attribué à un autre compte.',403) try: title=valid_name(request.data.get('display_name')) except ValueError as ex:return error(str(ex)) @@ -110,6 +122,9 @@ def update_member(request,space_id): setattr(child,key,request.data[key]);changes.append(key) if child.can_write_parent and not child.can_view_parent: return error('L’écriture nécessite la lecture de l’espace parent.') + try:enforce_delegation(request.user,child.parent,view=child.can_view_parent, + write=child.can_write_parent,invite=child.can_invite) + except ValueError as exc:return error(str(exc),403) if 'quota_bytes' in request.data: try:_update_quota(child, request.data['quota_bytes']) except ValueError as ex:return error(str(ex)) @@ -149,8 +164,10 @@ def create_invitation(request): if not manager(request.user,parent):return error('Vous ne pouvez pas inviter depuis cet espace.',403) fields=permission_flags(request.data) if 'can_view_parent' not in request.data:fields['can_view_parent']=True - if fields['can_write_parent'] and not fields['can_view_parent']: - return error('L’écriture nécessite la lecture de l’espace parent.') + if not fields['can_view_parent']: + return error('L’invitation donne nécessairement accès en lecture à cet espace.') + try:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite']) + except ValueError as exc:return error(str(exc),403) try:days=int(request.data.get('expires_days',7)) except (ValueError,TypeError):return error('Durée invalide.') if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.') @@ -207,14 +224,13 @@ def accept_invitation(request,token): if get_valid_invitation(token) is None:return error('Invitation invalide ou expirée.',404) username=str(request.data.get('username','')).strip() password=request.data.get('password','') - display_name=str(request.data.get('display_name','')).strip() email=str(request.data.get('email','')).strip() first_name=str(request.data.get('first_name','')).strip() last_name=str(request.data.get('last_name','')).strip() if not first_name or not last_name or len(first_name)>150 or len(last_name)>150: return error('Prénom et nom obligatoires (150 caractères maximum).') - if not username or len(username)>150 or not display_name or len(display_name)>120: - return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 caractères maximum).') + if not username or len(username)>150: + return error('Nom d’utilisateur obligatoire (150 caractères maximum).') User=get_user_model() try: User._meta.get_field(User.USERNAME_FIELD).run_validators(username) @@ -226,15 +242,17 @@ def accept_invitation(request,token): with transaction.atomic(): inv=get_valid_invitation(token,lock=True) if inv is None:return error('Invitation invalide ou déjà utilisée.',404) - with locked_quota_tree(inv.parent_space): - user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name) - space=create_space(user,inv.parent_space,display_name, - can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent, - can_invite=inv.can_invite) + enforce_delegation(inv.created_by,inv.parent_space,view=True, + write=inv.can_write_parent,invite=inv.can_invite) + user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name) + SpaceGrant.objects.create(user=user,space=inv.parent_space, + can_write=inv.can_write_parent,can_invite=inv.can_invite) + space=inv.parent_space ensure_private_space(user) inv.used_by=user;inv.used_at=timezone.now() inv.save(update_fields=['used_by','used_at']) except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.') + except ValueError as exc:return error(str(exc),403) return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201) @@ -289,13 +307,15 @@ def add_existing_user(request): return error('Utilisateur introuvable.',404) if account.pk == parent.user_id or account.pk == request.user.pk: return error('Impossible de vous ajouter vous-même à cet espace.') - own = ensure_space(account) - if own.parent_id == parent.pk: + own = DriveSpace.objects.filter(user=account, private_owner__isnull=True).first() + if own and own.parent_id == parent.pk: return error('Cet utilisateur est déjà un sous-utilisateur direct de cet espace.') can_write=request.data.get('can_write', False) can_invite=request.data.get('can_invite', False) if not isinstance(can_write, bool) or not isinstance(can_invite, bool): return error('Permissions invalides.') + try:enforce_delegation(request.user,parent,write=can_write,invite=can_invite) + except ValueError as exc:return error(str(exc),403) with transaction.atomic(): grant,created = SpaceGrant.objects.select_for_update().get_or_create( user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite}) @@ -320,6 +340,8 @@ def edit_existing_user(request, grant_id): if not isinstance(request.data[key],bool): return error('Permission invalide.') setattr(grant,key,request.data[key]) + try:enforce_delegation(request.user,grant.space,write=grant.can_write,invite=grant.can_invite) + except ValueError as exc:return error(str(exc),403) grant.save(update_fields=['can_write','can_invite']) return Response(grant_info(grant)) @@ -348,7 +370,8 @@ def create_shared_space(request): def edit_shared_space(request,space_id): try:space=DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id) except DriveSpace.DoesNotExist:return error('Espace introuvable.',404) - if space.private_owner_id or not manager(request.user,space):return error('Modification interdite.',403) + if space.private_owner_id or not (is_admin(request.user) or space.managed_by_id == request.user.pk or (space.user_id == request.user.pk and manager(request.user,space))): + return error('Modification interdite.',403) if request.method == 'DELETE': return delete_shared_space(request, space_id) try: @@ -438,3 +461,24 @@ def delete_shared_space(request, space_id): transaction.on_commit(lambda: _remove_deleted_space_files( space.relative_path, legacy_locations, upload_ids)) return Response({'deleted_space_ids': ids}) + + +@api_view(['GET']) +def admin_users(request): + if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403) + query = str(request.query_params.get('q','')).strip()[:100] + User = get_user_model() + qs = User.objects.order_by('username') + if query: + qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query)) + result=[] + for user in qs[:201]: + assigned=DriveSpace.objects.filter(user=user,private_owner__isnull=True).first() + if not assigned: + grant=SpaceGrant.objects.filter(user=user).order_by('created_at','pk').first() + assigned=grant.space if grant else None + result.append({'id':user.pk,'username':user.username,'first_name':user.first_name, + 'last_name':user.last_name,'email':user.email,'is_active':user.is_active, + 'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None, + 'date_joined':user.date_joined.isoformat()}) + return Response({'users':result[:200], 'limited':len(result)>200}) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index eb80789..f69904c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -58,6 +58,14 @@ api.interceptors.response.use(response => response, async error => { }) 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), + 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}/`), + publicBrowse:(token,parent)=>api.get(`/public/${encodeURIComponent(token)}/`,{params:parent?{parent}:{}}).then(r=>r.data), + adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data), stats:params=>api.get('/drive/stats/',{params}).then(r=>r.data), folder:(name,parent,space)=>api.post('/drive/folders/',{name,parent,space}).then(r=>r.data), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data), @@ -107,3 +115,5 @@ export const drive={ inviteAccept:(token,data)=>api.post(`/invitations/${encodeURIComponent(token)}/accept/`,data).then(r=>r.data), } 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) => `${api.defaults.baseURL}/public/${encodeURIComponent(token)}/files/${id}/` diff --git a/frontend/src/components/drive/ContextActions.jsx b/frontend/src/components/drive/ContextActions.jsx index 3c64c36..813cb08 100644 --- a/frontend/src/components/drive/ContextActions.jsx +++ b/frontend/src/components/drive/ContextActions.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users } from 'lucide-react' +import { Eye, Download, Pencil, FolderInput, Trash2, RotateCcw, Settings2, Users, Share2 } from 'lucide-react' export const fileOptions = (item, trash) => { if (!item) return [] @@ -11,6 +11,7 @@ export const fileOptions = (item, trash) => { 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 ? [ { id: 'rename', title: 'Renommer', icon: Pencil }, { id: 'move', title: 'Déplacer', icon: FolderInput }, @@ -20,7 +21,7 @@ export const fileOptions = (item, trash) => { } export const spaceOptions = (space) => space?.can_invite ? [ - {id:'manage',title:'Paramètres de l’espace',icon:Settings2}, + ...(space.can_edit ? [{id:'manage',title:'Paramètres de l’espace',icon:Settings2}] : []), {id:'members',title:'Accès et invitations',icon:Users}, ...(space.has_account || space.is_private || !space.parent_id ? [] : [{id:'delete',title:'Supprimer l’espace',icon:Trash2,danger:true}]), ] : [] diff --git a/frontend/src/components/drive/DriveDialogs.jsx b/frontend/src/components/drive/DriveDialogs.jsx index 5a0ba64..0f435eb 100644 --- a/frontend/src/components/drive/DriveDialogs.jsx +++ b/frontend/src/components/drive/DriveDialogs.jsx @@ -5,12 +5,14 @@ import PreviewDialog from './dialogs/PreviewDialog' import SpaceSettingsDialog from './dialogs/SpaceSettingsDialog' import MembersDialog from './dialogs/MembersDialog' import DeleteSpaceDialog from './dialogs/DeleteSpaceDialog' +import ShareItemDialog from './dialogs/ShareItemDialog' /** Groups the Drive's existing dialogs without coupling them to the page markup. */ export default function DriveDialogs({ controller, user }) { return <> + diff --git a/frontend/src/components/drive/DriveExplorer.jsx b/frontend/src/components/drive/DriveExplorer.jsx index 4889e23..b4a7de5 100644 --- a/frontend/src/components/drive/DriveExplorer.jsx +++ b/frontend/src/components/drive/DriveExplorer.jsx @@ -31,7 +31,7 @@ export default function DriveExplorer({ controller, user }) { const title = search ? `« ${search} »` - : trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : headerNames[view] + : trail.length && !search ? trail.at(-1)?.name : view === 'files' ? (activeSpace?.display_name || headerNames[view]) : (view === 'shared' ? 'Partagés avec moi' : headerNames[view]) return (
@@ -45,14 +45,14 @@ export default function DriveExplorer({ controller, user }) {
{isTrash ? Tous les espaces accessibles - : } - {view === 'files' && !search && trail.map(folder => ( + : } + {(view === 'files' || view === 'shared') && !search && trail.map(folder => ( ))} - {view !== 'files' && <> + {view !== 'files' && view !== 'shared' && <> {headerNames[view]} } @@ -96,13 +96,13 @@ export default function DriveExplorer({ controller, user }) {

- {isTrash ? 'La corbeille est vide' : search ? 'Aucun résultat' : 'Cet espace est vide'} + {isTrash ? 'La corbeille est vide' : search ? 'Aucun résultat' : view === 'shared' ? 'Aucun fichier partagé' : 'Cet espace est vide'}

{isTrash ? 'Les fichiers supprimés apparaîtront ici avant leur suppression définitive.' : search ? 'Essaie un autre nom de fichier.' - : 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'} + : view === 'shared' ? 'Les éléments que d’autres utilisateurs partagent avec toi apparaîtront ici.' : 'Importe tes premiers fichiers ou crée un dossier pour commencer à organiser ton espace.'}

{!isTrash && !search && canWrite && (
diff --git a/frontend/src/components/drive/DriveSidebar.jsx b/frontend/src/components/drive/DriveSidebar.jsx index b80fc0d..989250b 100644 --- a/frontend/src/components/drive/DriveSidebar.jsx +++ b/frontend/src/components/drive/DriveSidebar.jsx @@ -2,7 +2,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 { Database, FolderPlus, LogOut, FolderLock, FolderOpen, X } 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' @@ -103,7 +103,7 @@ export default function DriveSidebar({ controller, user, onLogout }) { Explorer