nice
This commit is contained in:
parent
c11c661897
commit
7da4c7cd67
@ -1,6 +1,5 @@
|
||||
"""Administration of users and shared spaces, without access to personal files."""
|
||||
from django.contrib import admin
|
||||
from django.db.models import Q
|
||||
from .models import DriveSpace, SpaceInvitation, SpaceGrant
|
||||
|
||||
|
||||
@ -17,22 +16,19 @@ class SpaceAdmin(admin.ModelAdmin):
|
||||
return base
|
||||
|
||||
def get_queryset(self, request):
|
||||
# Private spaces from other users never appear, even for a superuser.
|
||||
return super().get_queryset(request).filter(
|
||||
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)
|
||||
# The Django admin must never expose personal storage records.
|
||||
return super().get_queryset(request).filter(private_owner__isnull=True)
|
||||
|
||||
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):
|
||||
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):
|
||||
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)
|
||||
|
||||
18
backend/drive/migrations/0016_archived_drive_account.py
Normal file
18
backend/drive/migrations/0016_archived_drive_account.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -122,3 +122,9 @@ class PublicItemLink(models.Model):
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -14,28 +14,24 @@ def is_admin(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()
|
||||
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()
|
||||
grant = SpaceGrant.objects.filter(user=user).select_related('space').order_by('created_at', 'pk').first()
|
||||
if grant:
|
||||
return grant.space
|
||||
if not is_admin(user):
|
||||
return None
|
||||
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()
|
||||
if root is None:
|
||||
if not is_admin(user):
|
||||
raise PermissionError('Un administrateur doit initialiser le Drive.')
|
||||
obj = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine')
|
||||
else:
|
||||
obj = create_space(user, root, user.username)
|
||||
return obj
|
||||
root = DriveSpace.objects.create(user=user, relative_path='', display_name='Racine')
|
||||
return root
|
||||
|
||||
|
||||
def _make_numbered_space(parent, **fields):
|
||||
@ -105,6 +101,10 @@ def ensure_private_space(user):
|
||||
if obj:
|
||||
return obj
|
||||
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.
|
||||
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):
|
||||
if space is None:
|
||||
return False
|
||||
if not user.is_authenticated or not user.is_active:
|
||||
return False
|
||||
# Personal files are never subject to administrative or inherited access.
|
||||
@ -128,14 +130,16 @@ 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 and own.user_id == user.pk:
|
||||
if own and own.pk == space.pk and own.user_id == user.pk:
|
||||
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 _grant(user, space) is not None
|
||||
|
||||
|
||||
def can_write_space(user, space):
|
||||
if space is None:
|
||||
return False
|
||||
if not user.is_authenticated or not user.is_active:
|
||||
return False
|
||||
# Personal files are never subject to administrative or inherited access.
|
||||
@ -144,15 +148,17 @@ 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 and own.user_id == user.pk:
|
||||
if own and own.pk == space.pk and own.user_id == user.pk:
|
||||
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
|
||||
grant = _grant(user, space)
|
||||
return bool(grant and grant.can_write)
|
||||
|
||||
|
||||
def can_manage_space(user, space):
|
||||
if space is None:
|
||||
return False
|
||||
if not user.is_authenticated or not user.is_active or space.private_owner_id:
|
||||
return False
|
||||
if is_admin(user) or space.managed_by_id == user.pk:
|
||||
@ -187,7 +193,8 @@ def space_info(space, user):
|
||||
'is_private': bool(space.private_owner_id),
|
||||
'can_read': can_read_space(user, space), 'can_write': can_write_space(user, space),
|
||||
'can_invite': can_manage_space(user, space),
|
||||
'can_edit': 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,
|
||||
**quota_state(space),
|
||||
'can_view_parent': space.can_view_parent, 'can_write_parent': space.can_write_parent,
|
||||
|
||||
121
backend/drive/test_protected_deletions.py
Normal file
121
backend/drive/test_protected_deletions.py
Normal 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)
|
||||
@ -4,6 +4,7 @@ from . import views_spaces, views_shares
|
||||
urlpatterns=[
|
||||
path('spaces/',views_spaces.spaces),
|
||||
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('items/<uuid:item_id>/shares/',views_shares.manage_item_shares),
|
||||
path('items/<uuid:item_id>/shares/<int:grant_id>/',views_shares.remove_item_share),
|
||||
|
||||
@ -21,7 +21,7 @@ def me(request):
|
||||
return Response({'id':request.user.pk,'username':request.user.username,
|
||||
'is_admin':is_admin(request.user),'first_name':request.user.first_name,
|
||||
'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'])
|
||||
|
||||
@ -15,7 +15,8 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
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 .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,
|
||||
@ -57,20 +58,21 @@ def spaces(request):
|
||||
if is_admin(request.user):
|
||||
permitted=[private, *qs.filter(private_owner__isnull=True)]
|
||||
else:
|
||||
permitted=[private,own]
|
||||
if own.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id:
|
||||
permitted=[private] + ([own] if own else [])
|
||||
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.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=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]})
|
||||
|
||||
|
||||
@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):
|
||||
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)
|
||||
try:
|
||||
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):
|
||||
"""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()
|
||||
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)
|
||||
return
|
||||
# Old files may predate the numbered storage directories.
|
||||
for location in legacy_locations:
|
||||
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:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
@ -410,19 +412,47 @@ def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
|
||||
shutil.rmtree(folder)
|
||||
except OSError:
|
||||
_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()
|
||||
for upload_id in temp_ids:
|
||||
for upload_id in upload_ids:
|
||||
try:
|
||||
(temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
|
||||
except OSError:
|
||||
_logger.exception('Unable to remove canceled upload %s', upload_id)
|
||||
|
||||
|
||||
def delete_shared_space(request, space_id):
|
||||
"""Permanently delete a manually managed space and its independent descendants.
|
||||
def _clean_root_shared_files(file_locations, folder_locations, upload_ids):
|
||||
"""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')
|
||||
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)
|
||||
except DriveSpace.DoesNotExist:
|
||||
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:
|
||||
return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403)
|
||||
if not manager(request.user, space):
|
||||
if space.private_owner_id:
|
||||
return error('Un espace privé ne peut jamais être supprimé.', 403)
|
||||
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)
|
||||
if confirm_name != space.display_name:
|
||||
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.
|
||||
list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id))
|
||||
descendants = list(DriveSpace.objects.select_for_update().filter(
|
||||
Q(relative_path=space.relative_path) |
|
||||
Q(relative_path__startswith=space.relative_path + '/')
|
||||
).order_by('-relative_path'))
|
||||
if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants):
|
||||
return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409)
|
||||
ids = [child.pk for child in descendants]
|
||||
files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
|
||||
'storage_location', 'storage_key'))
|
||||
legacy_locations = [location or f'{key[:2]}/{key}'
|
||||
for location, key in files if key and not location]
|
||||
# Lock entire hierarchy to make re-parenting deterministic.
|
||||
all_spaces = list(DriveSpace.objects.select_for_update().order_by('pk'))
|
||||
# Do not remove any shared directory while a legacy private directory
|
||||
# still lives outside storage/privates. Migrate its physical layout first.
|
||||
if any(node.private_owner_id and not node.relative_path.startswith('privates/')
|
||||
for node in all_spaces):
|
||||
return error('Déplace d’abord les espaces privés vers storage/privates avant toute suppression.', 409)
|
||||
is_root_reset = space.pk == root.pk
|
||||
if is_root_reset and not admin:
|
||||
return error('Suppression interdite.', 403)
|
||||
prefix = space.relative_path + '/' if space.relative_path else ''
|
||||
targets = [node for node in all_spaces if not node.private_owner_id and (
|
||||
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(
|
||||
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()
|
||||
for child in descendants:
|
||||
child.delete() # bottom-up, as DriveSpace.parent uses PROTECT
|
||||
transaction.on_commit(lambda: _remove_deleted_space_files(
|
||||
space.relative_path, legacy_locations, upload_ids))
|
||||
return Response({'deleted_space_ids': ids})
|
||||
# Foreign keys to these spaces cascade (grants, invitations, uploads).
|
||||
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(
|
||||
space.relative_path, legacy, upload_ids))
|
||||
return Response({'deleted_space_ids': ids, 'root_reset': is_root_reset})
|
||||
|
||||
|
||||
@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)
|
||||
query = str(request.query_params.get('q','')).strip()[:100]
|
||||
User = get_user_model()
|
||||
qs = User.objects.order_by('username')
|
||||
qs = User.objects.filter(drive_archive__isnull=True).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=[]
|
||||
@ -482,3 +546,43 @@ def admin_users(request):
|
||||
'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})
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@ -66,6 +66,7 @@ export const drive={
|
||||
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),
|
||||
adminDeleteUser:(id,confirm_username)=>api.delete(`/drive/admin/users/${id}/`,{data:{confirm_username}}),
|
||||
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),
|
||||
|
||||
@ -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}] : []),
|
||||
{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_invite ? [{id:'members',title:'Accès et invitations',icon:Users}] : []),
|
||||
...(space.can_delete ? [{id:'delete',title:space.parent_id ? 'Supprimer l’espace' : 'Vider la racine',icon:Trash2,danger:true}] : []),
|
||||
] : []
|
||||
|
||||
/** Keyboard-accessible viewport-clamped menu used on desktop right-click and mobile ···. */
|
||||
|
||||
@ -13,7 +13,7 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
const [spaceMenu,setSpaceMenu] = useState(null)
|
||||
const closeSpaceMenu=useCallback(()=>setSpaceMenu(null),[])
|
||||
useEffect(()=>{
|
||||
if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && space.can_invite)) closeSpaceMenu()
|
||||
if (spaceMenu && !spaces.some(space=>space.id===spaceMenu.space.id && (space.can_invite || space.can_delete))) closeSpaceMenu()
|
||||
},[spaces,spaceMenu,closeSpaceMenu])
|
||||
const showSpaceMenu=(space,x,y)=>{
|
||||
const options=spaceOptions(space)
|
||||
@ -88,13 +88,13 @@ export default function DriveSidebar({ controller, user, onLogout }) {
|
||||
</div>
|
||||
<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'}`}
|
||||
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)}
|
||||
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"/>}
|
||||
<span className="min-w-0 flex-1 truncate">{space.display_name}</span>
|
||||
</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"
|
||||
onClick={event=>mobileDots(event, data=>setSpaceMenu({space,...data}),spaceOptions(space))}><MoreHorizontal className="size-4" /></Button>}
|
||||
</div>)}
|
||||
|
||||
@ -33,7 +33,7 @@ export default function DeleteSpaceDialog({controller: c}) {
|
||||
}
|
||||
setSpaceDialog(null)
|
||||
await loadSpaces()
|
||||
setNote('Espace supprimé définitivement.')
|
||||
setNote(response.root_reset ? 'Contenu partagé de la racine supprimé.' : 'Espace supprimé définitivement.')
|
||||
} catch (exception) {
|
||||
const detail = message(exception)
|
||||
setLocalError(detail)
|
||||
@ -46,12 +46,12 @@ export default function DeleteSpaceDialog({controller: c}) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive"><Trash2 className="size-5"/> Supprimer l’espace</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
<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"/>
|
||||
<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>
|
||||
<form onSubmit={remove} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Users, LoaderCircle } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Users, LoaderCircle, Trash2 } from 'lucide-react'
|
||||
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'
|
||||
|
||||
export default function AdminUsersPage(){
|
||||
@ -8,6 +10,12 @@ export default function AdminUsersPage(){
|
||||
const [record,setRecord] = useState({users:[]})
|
||||
const [busy,setBusy] = useState(true)
|
||||
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(()=>{
|
||||
let active=true
|
||||
const timer=setTimeout(async()=>{
|
||||
@ -17,22 +25,48 @@ export default function AdminUsersPage(){
|
||||
finally{if(active)setBusy(false)}
|
||||
},query?300:0)
|
||||
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">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<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)}/>
|
||||
{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">
|
||||
<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}>
|
||||
<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.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>
|
||||
{!record.users.length && <p className="p-4 text-sm text-muted-foreground">Aucun utilisateur.</p>}
|
||||
</div>}
|
||||
{record.limited && <p className="text-sm text-muted-foreground">200 résultats affichés. Précise la recherche.</p>}
|
||||
</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>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user