This commit is contained in:
Louis Mazin 2026-10-14 16:35:14 +02:00
parent c11c661897
commit 7da4c7cd67
13 changed files with 370 additions and 82 deletions

View File

@ -1,6 +1,5 @@
"""Administration of users and shared spaces, without access to personal files.""" """Administration of users and shared spaces, without access to personal files."""
from django.contrib import admin from django.contrib import admin
from django.db.models import Q
from .models import DriveSpace, SpaceInvitation, SpaceGrant from .models import DriveSpace, SpaceInvitation, SpaceGrant
@ -17,22 +16,19 @@ class SpaceAdmin(admin.ModelAdmin):
return base return base
def get_queryset(self, request): def get_queryset(self, request):
# Private spaces from other users never appear, even for a superuser. # The Django admin must never expose personal storage records.
return super().get_queryset(request).filter( return super().get_queryset(request).filter(private_owner__isnull=True)
Q(private_owner__isnull=True) | Q(private_owner=request.user)
)
def _personal_other(self, request, obj):
return bool(obj and obj.private_owner_id and obj.private_owner_id != request.user.pk)
def has_view_permission(self, request, obj=None): def has_view_permission(self, request, obj=None):
return not self._personal_other(request, obj) and super().has_view_permission(request, obj) return (obj is None or not obj.private_owner_id) and super().has_view_permission(request, obj)
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
return not self._personal_other(request, obj) and super().has_change_permission(request, obj) return (obj is None or not obj.private_owner_id) and super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return not self._personal_other(request, obj) and super().has_delete_permission(request, obj) # All deletion goes through the guarded GoodDrive endpoint to avoid
# bypassing private-space preservation and on-disk cleanup.
return False
@admin.register(SpaceInvitation) @admin.register(SpaceInvitation)

View File

@ -0,0 +1,18 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [('drive', '0015_item_shares'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name='ArchivedDriveAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('deleted_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT,
related_name='drive_archive', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -122,3 +122,9 @@ class PublicItemLink(models.Model):
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+') created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='+')
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
revoked_at = models.DateTimeField(null=True, blank=True) revoked_at = models.DateTimeField(null=True, blank=True)
class ArchivedDriveAccount(models.Model):
"""Account removed from the app while its immutable private space survives."""
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive')
deleted_at = models.DateTimeField(auto_now_add=True)

View File

@ -14,28 +14,24 @@ def is_admin(user):
def ensure_space(user): def ensure_space(user):
"""Return an owned space, or the existing space granted to an invitee.""" """Return an accessible shared space, or None if access was revoked.
Never recreate a deleted assigned space or grant access to the root when
an administrator removes an account's last shared space.
"""
obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first() obj = DriveSpace.objects.filter(user=user, private_owner__isnull=True).first()
if obj: if obj:
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
return obj 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() grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at', 'pk').first()
if grant: if grant:
return grant.space return grant.space
if not is_admin(user):
return None
with transaction.atomic(): with transaction.atomic():
obj = DriveSpace.objects.select_for_update().filter(user=user, private_owner__isnull=True).first()
if obj:
return obj
root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('id').first() root = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).order_by('id').first()
if root is None: if root is None:
if not is_admin(user): root = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine')
raise PermissionError('Un administrateur doit initialiser le Drive.') return root
obj = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine')
else:
obj = create_space(user, root, user.username)
return obj
def _make_numbered_space(parent, **fields): def _make_numbered_space(parent, **fields):
@ -105,6 +101,10 @@ def ensure_private_space(user):
if obj: if obj:
return obj return obj
assigned = ensure_space(user) assigned = ensure_space(user)
if assigned is None:
assigned = DriveSpace.objects.filter(parent__isnull=True, private_owner__isnull=True).first()
if assigned is None:
raise PermissionError('Stockage principal non initialisé.')
# The 50-Go cap is only a limit on private uploads, not a reservation. # The 50-Go cap is only a limit on private uploads, not a reservation.
return _make_numbered_space(assigned, private_owner=user, display_name='Mes fichiers') return _make_numbered_space(assigned, private_owner=user, display_name='Mes fichiers')
@ -120,6 +120,8 @@ def _grant(user, space):
def can_read_space(user, space): def can_read_space(user, space):
if space is None:
return False
if not user.is_authenticated or not user.is_active: if not user.is_authenticated or not user.is_active:
return False return False
# Personal files are never subject to administrative or inherited access. # Personal files are never subject to administrative or inherited access.
@ -128,14 +130,16 @@ def can_read_space(user, space):
if is_admin(user) or space.managed_by_id == user.pk: if is_admin(user) or space.managed_by_id == user.pk:
return True return True
own = ensure_space(user) own = ensure_space(user)
if own.pk == space.pk and own.user_id == user.pk: if own and own.pk == space.pk and own.user_id == user.pk:
return True return True
if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent: if own and own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent:
return True return True
return _grant(user, space) is not None return _grant(user, space) is not None
def can_write_space(user, space): def can_write_space(user, space):
if space is None:
return False
if not user.is_authenticated or not user.is_active: if not user.is_authenticated or not user.is_active:
return False return False
# Personal files are never subject to administrative or inherited access. # Personal files are never subject to administrative or inherited access.
@ -144,15 +148,17 @@ def can_write_space(user, space):
if is_admin(user) or space.managed_by_id == user.pk: if is_admin(user) or space.managed_by_id == user.pk:
return True return True
own = ensure_space(user) own = ensure_space(user)
if own.pk == space.pk and own.user_id == user.pk: if own and own.pk == space.pk and own.user_id == user.pk:
return True return True
if own.user_id == user.pk and own.parent_id == space.pk and own.can_view_parent and own.can_write_parent: 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 return True
grant = _grant(user, space) grant = _grant(user, space)
return bool(grant and grant.can_write) return bool(grant and grant.can_write)
def can_manage_space(user, space): 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: if not user.is_authenticated or not user.is_active or space.private_owner_id:
return False return False
if is_admin(user) or space.managed_by_id == user.pk: if is_admin(user) or space.managed_by_id == user.pk:
@ -187,7 +193,8 @@ def space_info(space, user):
'is_private': bool(space.private_owner_id), 'is_private': bool(space.private_owner_id),
'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space), 'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space),
'can_invite': can_manage_space(user, space), '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)), '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)),
'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True, 'has_account': bool(space.user_id), 'is_active': owner.is_active if owner else True,
**quota_state(space), **quota_state(space),
'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent, 'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent,

View File

@ -0,0 +1,121 @@
"""Deletion contract: admin may remove shared spaces, never private spaces.
Run: USE_SQLITE=true python manage.py test drive.test_protected_deletions
"""
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, ArchivedDriveAccount
from .spaces import ensure_space, ensure_private_space, create_space
class ProtectedDeletionTests(TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.storage = Path(tmp.name)
override = override_settings(DRIVE_STORAGE_ROOT=self.storage,
FILE_UPLOAD_TEMP_DIR=str(self.storage / '.uploads'))
override.enable()
self.addCleanup(override.disable)
User = get_user_model()
self.admin = User.objects.create_superuser('test-admin', 'admin@example.org', 'VeryStrongPass123!')
self.alice = User.objects.create_user('test-alice', password='VeryStrongPass123!')
self.root = ensure_space(self.admin)
self.admin_private = ensure_private_space(self.admin)
self.assigned = create_space(self.alice, self.root, 'Alice partagé')
self.private = ensure_private_space(self.alice)
self.client = APIClient()
self.client.force_authenticate(user=self.admin)
def private_file(self):
item = DriveItem.objects.create(owner=self.alice, space=self.private,
name='mon-secret.txt', storage_key=str(uuid.uuid4()),
storage_location=f'{self.private.relative_path}/mon-secret.txt', size=6)
path = self.storage / item.storage_location
path.write_bytes(b'secret')
return item, path
def test_admin_cannot_browse_or_delete_any_other_private_space(self):
item, path = self.private_file()
result = self.client.get('/api/drive/spaces/')
self.assertEqual(result.status_code, 200, result.data)
self.assertNotIn(self.private.pk, [s['id'] for s in result.data['spaces']])
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 403)
self.assertEqual(self.client.get('/api/drive/stats/', {'space':self.private.pk}).status_code, 403)
self.assertEqual(self.client.post(f'/api/drive/items/{item.pk}/link/').status_code, 403)
self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/',
{'confirm_name':self.private.display_name}, format='json').status_code, 403)
self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.delete(f'/api/drive/spaces/{self.private.pk}/',
{'confirm_name':self.private.display_name}, format='json').status_code, 403)
self.assertTrue(path.is_file())
def test_admin_can_delete_assigned_shared_space_without_private_loss(self):
secret, path = self.private_file()
shared_path = self.storage / self.assigned.relative_path
self.assertTrue(shared_path.is_dir())
with self.captureOnCommitCallbacks(execute=True):
res = self.client.delete(f'/api/drive/spaces/{self.assigned.pk}/',
{'confirm_name':self.assigned.display_name}, format='json')
self.assertEqual(res.status_code, 200, res.data)
self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists())
self.assertFalse(shared_path.exists())
self.private.refresh_from_db()
self.assertEqual(self.private.parent_id, self.root.pk)
self.assertTrue(path.exists())
self.assertTrue(DriveItem.objects.filter(pk=secret.pk).exists())
self.client.force_authenticate(user=self.alice)
self.assertIsNone(ensure_space(self.alice))
self.assertIsNone(self.client.get('/api/auth/me/').data['space_id'])
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.private.pk}).status_code, 200)
self.assertEqual(self.client.get('/api/drive/items/', {'space':self.root.pk}).status_code, 403)
def test_admin_root_reset_keeps_root_and_private_files(self):
item, private_path = self.private_file()
root_folder = DriveItem.objects.create(owner=self.admin, space=self.root,
name='public-folder', is_folder=True)
public_path = self.storage / 'public-folder'
public_path.mkdir()
shared_path = self.storage / self.assigned.relative_path
self.assertTrue(shared_path.is_dir())
with self.captureOnCommitCallbacks(execute=True):
res = self.client.delete(f'/api/drive/spaces/{self.root.pk}/',
{'confirm_name':self.root.display_name}, format='json')
self.assertEqual(res.status_code, 200, res.data)
self.assertTrue(res.data['root_reset'])
self.assertTrue(DriveSpace.objects.filter(pk=self.root.pk).exists())
self.assertFalse(DriveSpace.objects.filter(pk=self.assigned.pk).exists())
self.assertFalse(DriveItem.objects.filter(pk=root_folder.pk).exists())
self.assertFalse(shared_path.exists())
self.assertFalse(public_path.exists())
self.assertTrue(private_path.exists())
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
self.assertTrue((self.storage/'privates').is_dir())
def test_user_deletion_anonymizes_account_but_keeps_private(self):
item, path = self.private_file()
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':'wrong'}, format='json').status_code, 400)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 204)
self.alice.refresh_from_db()
self.assertFalse(self.alice.is_active)
self.assertFalse(self.alice.has_usable_password())
self.assertTrue(ArchivedDriveAccount.objects.filter(user=self.alice).exists())
self.assertNotIn(self.alice.pk, [u['id'] for u in self.client.get('/api/drive/admin/users/').data['users']])
self.assertTrue(path.exists())
self.assertTrue(DriveSpace.objects.filter(pk=self.private.pk, private_owner=self.alice).exists())
self.assertTrue(DriveItem.objects.filter(pk=item.pk).exists())
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.alice.pk}/',
{'confirm_username':self.alice.username}, format='json').status_code, 404)
def test_non_admin_cannot_delete_other_users(self):
self.client.force_authenticate(user=self.alice)
self.assertEqual(self.client.delete(f'/api/drive/admin/users/{self.admin.pk}/',
{'confirm_username':self.admin.username}, format='json').status_code, 403)

View File

@ -4,6 +4,7 @@ from . import views_spaces, views_shares
urlpatterns=[ urlpatterns=[
path('spaces/',views_spaces.spaces), path('spaces/',views_spaces.spaces),
path('admin/users/',views_spaces.admin_users), path('admin/users/',views_spaces.admin_users),
path('admin/users/<int:user_id>/',views_spaces.admin_delete_user),
path('shares/users/search/',views_shares.search_share_users), path('shares/users/search/',views_shares.search_share_users),
path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares), path('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share), path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),

View File

@ -21,7 +21,7 @@ def me(request):
return Response({'id':request.user.pk,'username':request.user.username, return Response({'id':request.user.pk,'username':request.user.username,
'is_admin':is_admin(request.user),'first_name':request.user.first_name, 'is_admin':is_admin(request.user),'first_name':request.user.first_name,
'last_name':request.user.last_name,'private_space_id':private.pk, 'last_name':request.user.last_name,'private_space_id':private.pk,
'space_id':space.pk,'can_invite':space.can_invite or is_admin(request.user)}) 'space_id':space.pk if space else None,'can_invite':bool(space and space.can_invite) or is_admin(request.user)})
@api_view(['GET']) @api_view(['GET'])

View File

@ -15,7 +15,8 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle from rest_framework.throttling import AnonRateThrottle
from rest_framework.response import Response from rest_framework.response import Response
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession from .models import (DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession,
ItemGrant, PublicItemLink, ArchivedDriveAccount)
from .services import error, valid_name 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_write_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, from .quotas import (parse_quota, locked_quota_tree,
@ -57,20 +58,21 @@ def spaces(request):
if is_admin(request.user): if is_admin(request.user):
permitted=[private, *qs.filter(private_owner__isnull=True)] permitted=[private, *qs.filter(private_owner__isnull=True)]
else: else:
permitted=[private,own] permitted=[private] + ([own] if own else [])
if own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id: if own and 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.append(own.parent)
permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id) 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)) permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True))
permitted=list({space.pk:space for space in permitted}.values()) permitted=list({space.pk:space for space in permitted}.values())
return Response({'mine':private.pk, 'assigned':own.pk, 'private':private.pk, return Response({'mine':private.pk, 'assigned':own.pk if own else None, 'private':private.pk,
'spaces':[space_info(space,request.user) for space in permitted]}) 'spaces':[space_info(space,request.user) for space in permitted]})
@api_view(['PATCH']) @api_view(['PATCH'])
def rename_my_space(request): def rename_my_space(request):
own=my_space(request.user) own=my_space(request.user)
if own.user_id != request.user.pk and not is_admin(request.user): if own is None:return error('Aucun espace attribué.',404)
if own.private_owner_id or (own.user_id != request.user.pk and not is_admin(request.user)):
return error('Impossible de renommer un espace attribué à un autre compte.',403) return error('Impossible de renommer un espace attribué à un autre compte.',403)
try: try:
title=valid_name(request.data.get('display_name')) title=valid_name(request.data.get('display_name'))
@ -391,16 +393,16 @@ _logger = logging.getLogger(__name__)
def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids): def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
"""Executed only AFTER commit: keep SQL changes atomic before touching disk.""" """Remove only the shared directory after the DB transaction commits."""
root = Path(settings.DRIVE_STORAGE_ROOT).resolve() root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
folder = (root / relative_path).resolve() folder = (root / relative_path).resolve()
if folder == root or not folder.is_relative_to(root): private_root = root / 'privates'
if folder == root or not folder.is_relative_to(root) or folder.is_relative_to(private_root):
_logger.error('Invalid space path for disk cleanup: %s', relative_path) _logger.error('Invalid space path for disk cleanup: %s', relative_path)
return return
# Old files may predate the numbered storage directories.
for location in legacy_locations: for location in legacy_locations:
path = (root / location).resolve() path = (root / location).resolve()
if path.is_relative_to(root) and not path.is_relative_to(folder): if path.is_relative_to(root) and not path.is_relative_to(private_root) and not path.is_relative_to(folder):
try: try:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
except OSError: except OSError:
@ -410,19 +412,47 @@ def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
shutil.rmtree(folder) shutil.rmtree(folder)
except OSError: except OSError:
_logger.exception('Unable to remove deleted space directory: %s', folder) _logger.exception('Unable to remove deleted space directory: %s', folder)
_remove_upload_parts(temp_ids)
def _remove_upload_parts(upload_ids):
temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve() temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve()
for upload_id in temp_ids: for upload_id in upload_ids:
try: try:
(temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True) (temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
except OSError: except OSError:
_logger.exception('Unable to remove canceled upload %s', upload_id) _logger.exception('Unable to remove canceled upload %s', upload_id)
def delete_shared_space(request, space_id): def _clean_root_shared_files(file_locations, folder_locations, upload_ids):
"""Permanently delete a manually managed space and its independent descendants. """The root DB row and physical storage directory must survive a reset."""
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
private_root = root / 'privates'
# Remove only tracked data, not the private directories or unrelated host files.
for location in file_locations:
path = (root / location).resolve()
if path.is_relative_to(root) and not path.is_relative_to(private_root) and path.is_file():
try:
path.unlink()
except OSError:
_logger.exception('Cannot delete root file: %s', path)
for location in sorted(folder_locations, key=lambda x: len(x), reverse=True):
path = (root / location).resolve()
if path != root and path.is_relative_to(root) and not path.is_relative_to(private_root):
try:
if path.is_dir():
# Only remove empty dirs: unknown files are never destroyed.
path.rmdir()
except OSError:
pass
_remove_upload_parts(upload_ids)
Never delete root, assigned account spaces, or ANY private space, including a
private descendant. All cleanup is committed before disk deletion starts. def delete_shared_space(request, space_id):
"""Admins can remove all *shared* spaces; private spaces are immutable.
Deleting the root clears its shared contents but preserves its database
anchor and physical storage directory for all private spaces.
""" """
confirm_name = request.data.get('confirm_name') confirm_name = request.data.get('confirm_name')
if not isinstance(confirm_name, str): if not isinstance(confirm_name, str):
@ -432,35 +462,69 @@ def delete_shared_space(request, space_id):
space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id) space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id)
except DriveSpace.DoesNotExist: except DriveSpace.DoesNotExist:
return error('Espace introuvable.', 404) return error('Espace introuvable.', 404)
if not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id: if space.private_owner_id:
return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403) return error('Un espace privé ne peut jamais être supprimé.', 403)
if not manager(request.user, space): root = DriveSpace.objects.select_for_update().filter(
parent__isnull=True, private_owner__isnull=True).first()
if root is None:
return error('Espace racine introuvable.', 409)
admin = is_admin(request.user)
if not admin and (not space.parent_id or space.user_id or space.managed_by_id != request.user.pk):
return error('Suppression interdite.', 403) return error('Suppression interdite.', 403)
if confirm_name != space.display_name: if confirm_name != space.display_name:
return error('Le nom de confirmation ne correspond pas au nom de l’espace.') return error('Le nom de confirmation ne correspond pas au nom de l’espace.')
# Also lock the parent: creation and deletion of siblings are serialized. # Lock entire hierarchy to make re-parenting deterministic.
list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id)) all_spaces = list(DriveSpace.objects.select_for_update().order_by('pk'))
descendants = list(DriveSpace.objects.select_for_update().filter( # Do not remove any shared directory while a legacy private directory
Q(relative_path=space.relative_path) | # still lives outside storage/privates. Migrate its physical layout first.
Q(relative_path__startswith=space.relative_path + '/') if any(node.private_owner_id and not node.relative_path.startswith('privates/')
).order_by('-relative_path')) for node in all_spaces):
if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants): return error('Déplace d’abord les espaces privés vers storage/privates avant toute suppression.', 409)
return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409) is_root_reset = space.pk == root.pk
ids = [child.pk for child in descendants] if is_root_reset and not admin:
files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list( return error('Suppression interdite.', 403)
'storage_location', 'storage_key')) prefix = space.relative_path + '/' if space.relative_path else ''
legacy_locations = [location or f'{key[:2]}/{key}' targets = [node for node in all_spaces if not node.private_owner_id and (
for location, key in files if key and not location] is_root_reset or node.pk == space.pk or node.relative_path.startswith(prefix))]
to_delete = [node for node in targets if not is_root_reset or node.pk != root.pk]
ids = [node.pk for node in targets]
# Private spaces retain their location under storage/privates and are
# only relinked logically to root, so no private data is ever deleted.
for private in all_spaces:
if private.private_owner_id and private.parent_id in ids and private.parent_id != root.pk:
private.parent = root
private.save(update_fields=['parent'])
root_items = list(DriveItem.objects.filter(space=root)) if is_root_reset else []
root_files = [(i.storage_location or f'{i.storage_key[:2]}/{i.storage_key}')
for i in root_items if not i.is_folder and i.storage_key]
# Directories can be represented in SQL without an on-disk file path.
root_folders = []
if is_root_reset:
from .storage_layout import file_location
for item in root_items:
if item.is_folder:
root_folders.append(file_location(root, item.parent, item.name))
file_records = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
'space_id', 'storage_location', 'storage_key'))
legacy = [location or f'{key[:2]}/{key}' for sid, location, key in file_records
if key and not location and (not is_root_reset or sid != root.pk)]
upload_ids = list(UploadSession.objects.select_for_update().filter( upload_ids = list(UploadSession.objects.select_for_update().filter(
space_id__in=ids).values_list('pk', flat=True)) space_id__in=ids).values_list('pk', flat=True))
# Children of DriveItem use CASCADE; item ownership and other accounts
# are left intact, only metadata from these owned spaces is deleted.
DriveItem.objects.filter(space_id__in=ids).delete() DriveItem.objects.filter(space_id__in=ids).delete()
for child in descendants: # Foreign keys to these spaces cascade (grants, invitations, uploads).
child.delete() # bottom-up, as DriveSpace.parent uses PROTECT for node in sorted(to_delete, key=lambda x: len(x.relative_path), reverse=True):
node.delete()
if is_root_reset:
# Clean each shared child directory, but NEVER storage/privates.
top_dirs = [node.relative_path for node in to_delete
if node.parent_id == root.pk]
transaction.on_commit(lambda: (
[_remove_deleted_space_files(location, [], []) for location in top_dirs],
_clean_root_shared_files(root_files + legacy, root_folders, upload_ids)))
else:
transaction.on_commit(lambda: _remove_deleted_space_files( transaction.on_commit(lambda: _remove_deleted_space_files(
space.relative_path, legacy_locations, upload_ids)) space.relative_path, legacy, upload_ids))
return Response({'deleted_space_ids': ids}) return Response({'deleted_space_ids': ids, 'root_reset': is_root_reset})
@api_view(['GET']) @api_view(['GET'])
@ -468,7 +532,7 @@ def admin_users(request):
if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403) if not is_admin(request.user):return error('Accès réservé aux administrateurs.',403)
query = str(request.query_params.get('q','')).strip()[:100] query = str(request.query_params.get('q','')).strip()[:100]
User = get_user_model() User = get_user_model()
qs = User.objects.order_by('username') qs = User.objects.filter(drive_archive__isnull=True).order_by('username')
if query: if query:
qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query)) qs = qs.filter(Q(username__icontains=query)|Q(first_name__icontains=query)|Q(last_name__icontains=query)|Q(email__icontains=query))
result=[] result=[]
@ -482,3 +546,43 @@ def admin_users(request):
'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None, 'is_admin':is_admin(user), 'space_name':assigned.display_name if assigned else None,
'date_joined':user.date_joined.isoformat()}) 'date_joined':user.date_joined.isoformat()})
return Response({'users':result[:200], 'limited':len(result)>200}) return Response({'users':result[:200], 'limited':len(result)>200})
@api_view(['DELETE'])
def admin_delete_user(request, user_id):
"""Remove account access without deleting the non-deletable private space.
The user row remains as an anonymized, inactive data owner so PROTECT
relations on private files and the private space never cascade away.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
with transaction.atomic():
try:
target = User.objects.select_for_update().get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if target.pk == request.user.pk or is_admin(target):
return error('Impossible de supprimer un administrateur.', 403)
if request.data.get('confirm_username') != target.username:
return error('Saisis le nom d’utilisateur pour confirmer.')
now = timezone.now()
SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now)
PublicItemLink.objects.filter(Q(created_by=target) | Q(item__space__private_owner=target),
revoked_at__isnull=True).update(revoked_at=now)
ItemGrant.objects.filter(Q(user=target) | Q(created_by=target)).delete()
SpaceGrant.objects.filter(user=target).delete()
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk', flat=True))
UploadSession.objects.filter(owner=target).delete()
if unfinished:
transaction.on_commit(lambda: _remove_upload_parts(unfinished))
target.is_active = False
target.username = f'supprime-{target.pk}-{secrets.token_hex(5)}'
target.first_name = ''
target.last_name = ''
target.email = ''
target.set_unusable_password()
target.save(update_fields=['is_active', 'username', 'first_name', 'last_name', 'email', 'password'])
ArchivedDriveAccount.objects.create(user=target)
return Response(status=204)

View File

@ -66,6 +66,7 @@ export const drive={
revokePublic:(id,link_id)=>api.delete(`/drive/items/${id}/public-links/${link_id}/`), 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), 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), adminUsers:q=>api.get('/drive/admin/users/',{params:{q}}).then(r=>r.data),
adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}),
stats:params=>api.get('/drive/stats/',{params}).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), 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), patch:(id,updates)=>api.patch(`/drive/items/${id}/`,updates).then(r=>r.data),

View File

@ -20,10 +20,10 @@ export const fileOptions = (item, trash) => {
] ]
} }
export const spaceOptions = (space) => space?.can_invite ? [ export const spaceOptions = (space) => (space?.can_invite || space?.can_delete) ? [
...(space.can_edit ? [{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.can_invite ? [{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}]), ...(space.can_delete ? [{id:'delete',title:space.parent_id ? 'Supprimer l’espace' : 'Vider la racine',icon:Trash2,danger:true}] : []),
] : [] ] : []
/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */ /** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */

View File

@ -13,7 +13,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
const [spaceMenu,setSpaceMenu] = useState(null) const [spaceMenu,setSpaceMenu] = useState(null)
const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[]) const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[])
useEffect(()=>{ useEffect(()=>{
if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && space.can_invite)) closeSpaceMenu() if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete))) closeSpaceMenu()
},[spaces,spaceMenu,closeSpaceMenu]) },[spaces,spaceMenu,closeSpaceMenu])
const showSpaceMenu=(space,x,y)=>{ const showSpaceMenu=(space,x,y)=>{
const options=spaceOptions(space) const options=spaceOptions(space)
@ -88,13 +88,13 @@ export default function DriveSidebar({ controller, user, onLogout }) {
</div> </div>
<div className="space-y-1 pb-4"> <div className="space-y-1 pb-4">
{shared.map(space => <div key={space.id} className={`group flex min-h-10 items-center rounded-xl ${activeSpaceId===space.id ? 'bg-accent text-primary font-semibold':'text-muted-foreground hover:bg-muted'}`} {shared.map(space => <div key={space.id} className={`group flex min-h-10 items-center rounded-xl ${activeSpaceId===space.id ? 'bg-accent text-primary font-semibold':'text-muted-foreground hover:bg-muted'}`}
onContextMenu={event=>{if(space.can_invite){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}> onContextMenu={event=>{if(space.can_invite||space.can_delete){event.preventDefault();showSpaceMenu(space,event.clientX,event.clientY)}}}>
<button type="button" onClick={()=>switchSpace(space.id)} <button type="button" onClick={()=>switchSpace(space.id)}
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm"> className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-sm">
{space.can_write ? <FolderOpen className="size-4 shrink-0"/>:<FolderLock className="size-4 shrink-0"/>} {space.can_write ? <FolderOpen className="size-4 shrink-0"/>:<FolderLock className="size-4 shrink-0"/>}
<span className="min-w-0 flex-1 truncate">{space.display_name}</span> <span className="min-w-0 flex-1 truncate">{space.display_name}</span>
</button> </button>
{space.can_invite && <Button type="button" variant="ghost" size="icon-sm" title="Gérer l’espace" {(space.can_invite || space.can_delete) && <Button type="button" variant="ghost" size="icon-sm" title="Gérer l’espace"
aria-label={`Gérer ${space.display_name}`} className="mr-1 shrink-0 md:hidden" aria-label={`Gérer ${space.display_name}`} className="mr-1 shrink-0 md:hidden"
onClick={event=>mobileDots(event, data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4" /></Button>} onClick={event=>mobileDots(event, data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4" /></Button>}
</div>)} </div>)}

View File

@ -33,7 +33,7 @@ export default function DeleteSpaceDialog({controller: c}) {
} }
setSpaceDialog(null) setSpaceDialog(null)
await loadSpaces() await loadSpaces()
setNote('Espace supprimé définitivement.') setNote(response.root_reset ? 'Contenu partagé de la racine supprimé.' : 'Espace supprimé définitivement.')
} catch (exception) { } catch (exception) {
const detail = message(exception) const detail = message(exception)
setLocalError(detail) setLocalError(detail)
@ -46,12 +46,12 @@ export default function DeleteSpaceDialog({controller: c}) {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive"><Trash2 className="size-5"/> Supprimer l’espace</DialogTitle> <DialogTitle className="flex items-center gap-2 text-destructive"><Trash2 className="size-5"/> Supprimer l’espace</DialogTitle>
<DialogDescription> <DialogDescription>
L’espace « {space?.display_name} », ses fichiers, sa corbeille, ses transferts et ses sous-espaces autonomes seront supprimés définitivement. {space?.parent_id ? `L’espace « ${space.display_name} » et ses sous-espaces partagés seront supprimés.` : 'Tous les fichiers et sous-espaces partagés de la racine seront supprimés. La racine restera en place.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"> <div className="flex gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-destructive"/> <AlertTriangle className="mt-0.5 size-5 shrink-0 text-destructive"/>
<p>Action irréversible. Si l’espace contient des sous-utilisateurs avec leur propre compte, la suppression sera bloquée pour protéger leurs données privées.</p> <p>Action irréversible. Les espaces privés et leurs fichiers seront conservés.</p>
</div> </div>
<form onSubmit={remove} className="space-y-4"> <form onSubmit={remove} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -1,6 +1,8 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Users, LoaderCircle } from 'lucide-react' import { Users, LoaderCircle, Trash2 } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { drive, message } from '@/api/client' import { drive, message } from '@/api/client'
export default function AdminUsersPage(){ export default function AdminUsersPage(){
@ -8,6 +10,12 @@ export default function AdminUsersPage(){
const [record,setRecord] = useState({users:[]}) const [record,setRecord] = useState({users:[]})
const [busy,setBusy] = useState(true) const [busy,setBusy] = useState(true)
const [error,setError] = useState('') const [error,setError] = useState('')
const [selected,setSelected] = useState(null)
const [confirmation,setConfirmation] = useState('')
const [deleting,setDeleting] = useState(false)
const [deleteError,setDeleteError] = useState('')
const [revision,setRevision] = useState(0)
const refresh=useCallback(()=>setRevision(n=>n+1),[])
useEffect(()=>{ useEffect(()=>{
let active=true let active=true
const timer=setTimeout(async()=>{ const timer=setTimeout(async()=>{
@ -17,22 +25,48 @@ export default function AdminUsersPage(){
finally{if(active)setBusy(false)} finally{if(active)setBusy(false)}
},query?300:0) },query?300:0)
return ()=>{active=false;clearTimeout(timer)} return ()=>{active=false;clearTimeout(timer)}
},[query]) },[query,revision])
const close=()=>{if(!deleting){setSelected(null);setConfirmation('');setDeleteError('')}}
const remove=async(event)=>{
event.preventDefault()
if(!selected||deleting||confirmation!==selected.username)return
setDeleting(true);setDeleteError('')
try{await drive.adminDeleteUser(selected.id,confirmation);setSelected(null);setConfirmation('');refresh()}
catch(e){setDeleteError(message(e))}
finally{setDeleting(false)}
}
return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7"> return <div className="min-h-0 flex-1 overflow-auto p-4 sm:p-7">
<div className="mx-auto max-w-5xl space-y-5"> <div className="mx-auto max-w-5xl space-y-5">
<h1 className="flex items-center gap-3 text-2xl font-semibold"><Users/> Utilisateurs</h1> <h1 className="flex items-center gap-3 text-2xl font-semibold"><Users/> Utilisateurs</h1>
<Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/> <Input placeholder="Rechercher par nom, identifiant ou e-mail…" value={query} onChange={e=>setQuery(e.target.value)}/>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
{busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card"> {busy?<LoaderCircle className="size-6 animate-spin"/>:<div className="overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th></tr></thead> <table className="w-full min-w-[650px] text-left text-sm"><thead className="bg-muted/50 text-muted-foreground"><tr><th className="p-3">Utilisateur</th><th className="p-3">Identifiant</th><th className="p-3">E-mail</th><th className="p-3">Espace attribué</th><th className="p-3">Statut</th><th className="p-3 text-right">Actions</th></tr></thead>
<tbody className="divide-y">{record.users.map(user=><tr key={user.id}> <tbody className="divide-y">{record.users.map(user=><tr key={user.id}>
<td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td> <td className="p-3 font-medium">{[user.first_name,user.last_name].filter(Boolean).join(' ') || '—'}</td>
<td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td> <td className="p-3">@{user.username}</td><td className="p-3">{user.email||'—'}</td><td className="p-3">{user.space_name||'—'}</td>
<td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td> <td className="p-3">{user.is_active?'Actif':'Désactivé'}{user.is_admin?' · Admin':''}</td>
<td className="p-2 text-right">{!user.is_admin && <Button type="button" variant="ghost" size="icon-sm" className="text-destructive" aria-label={`Supprimer ${user.username}`} title="Supprimer l’utilisateur" onClick={()=>{setSelected(user);setConfirmation('');setDeleteError('')}}><Trash2 className="size-4"/></Button>}</td>
</tr>)}</tbody></table> </tr>)}</tbody></table>
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>} {!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
</div>} </div>}
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>} {record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
</div> </div>
<Dialog open={!!selected} onOpenChange={open=>{if(!open)close()}}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>Supprimer l’utilisateur</DialogTitle>
<DialogDescription>Le compte sera désactivé et retiré de la liste. Son espace privé sera conservé.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={remove}>
<label className="block space-y-2 text-sm">Tape « {selected?.username} » pour confirmer
<Input value={confirmation} autoComplete="off" autoFocus disabled={deleting} onChange={e=>setConfirmation(e.target.value)} />
</label>
{deleteError && <p className="text-sm text-destructive" role="alert">{deleteError}</p>}
<DialogFooter><Button type="button" variant="outline" disabled={deleting} onClick={close}>Annuler</Button>
<Button type="submit" variant="destructive" disabled={deleting||confirmation!==selected?.username}>{deleting?'Suppression…':'Supprimer'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div> </div>
} }