GooDrive/backend/drive/views_spaces.py
Louis Mazin 90004d4540 nickel
2026-10-14 17:27:37 +02:00

745 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Invitation-only registration and hierarchical space management."""
import secrets
import logging
import shutil
from pathlib import Path
from django.conf import settings
from django.db.models import Q
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
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 (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 .permissions import RIGHTS, PRESETS, normalized, legacy_rights, space_rights, has_space_right, assert_delegation
from .quotas import (parse_quota, locked_quota_tree,
assert_new_child_quota, assert_updated_quota)
def permission_flags(data):
return {k: data.get(k) is True for k in ('can_view_parent','can_write_parent','can_invite')}
def manager(user, parent):
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 {'permissions': inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent, invite=inv.can_invite), 'id':str(inv.pk), 'created_at':inv.created_at.isoformat(),
'expires_at':inv.expires_at.isoformat(), 'used':bool(inv.used_at),
'revoked':bool(inv.revoked_at),
'can_view_parent':inv.can_view_parent,'can_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite}
@api_view(['GET'])
def spaces(request):
own = ensure_space(request.user)
qs=DriveSpace.objects.select_related('user','private_owner','managed_by','parent').order_by('relative_path')
private=ensure_private_space(request.user)
if is_admin(request.user):
permitted=[private, *qs.filter(private_owner__isnull=True)]
else:
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 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 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'))
except ValueError as ex:return error(str(ex))
if len(title)>120:return error('Nom limité à 120 caractères.')
own.display_name=title
own.save(update_fields=['display_name'])
return Response(space_info(own,request.user))
@api_view(['GET'])
def members(request):
own=my_space(request.user)
parent=own
if request.query_params.get('parent_space'):
try:parent=DriveSpace.objects.get(pk=int(request.query_params['parent_space']))
except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404)
if not manager(request.user,parent):return error('Gestion des invitations non autorisée.',403)
children=DriveSpace.objects.filter(parent=parent,private_owner__isnull=True).select_related('user','parent').order_by('id')
invitations=SpaceInvitation.objects.filter(parent_space=parent).order_by('-created_at')[:100]
# Existing memberships of the viewer/space manager are not directly added users.
excluded_ids = {request.user.pk, parent.user_id, parent.managed_by_id}
grants = (SpaceGrant.objects.filter(space=parent)
.exclude(user_id__in=[pk for pk in excluded_ids if pk is not None])
.select_related('user').order_by('user__username'))
return Response({'parent':space_info(parent,request.user),
'children':[space_info(c,request.user) for c in children if not c.private_owner_id],
'grants':[grant_info(g) for g in grants],
'invitations':[invitation_info(i) for i in invitations]})
def _update_quota(space, requested):
"""Must run under transaction.atomic and after locking all ancestors."""
if space.private_owner_id:
raise ValueError('Le quota des espaces privés est fixé à 50 Go.')
with locked_quota_tree(space):
quota = parse_quota(requested)
assert_updated_quota(space, quota)
space.quota_bytes = quota
space.save(update_fields=['quota_bytes'])
@api_view(['PATCH'])
def update_member(request,space_id):
with transaction.atomic():
try:child=DriveSpace.objects.select_related('parent','user').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if child.private_owner_id or not child.parent_id:return error('Impossible de modifier cet espace ici.',403)
if not has_space_right(request.user,child.parent,'manage_access'):return error('Modification interdite.',403)
changes=[]
if 'permissions' in request.data:
try:rights=assert_delegation(space_rights(request.user,child.parent),request.data['permissions'])
except ValueError as exc:return error(str(exc),403)
child.parent_permissions=rights;changes.append('parent_permissions')
child.can_view_parent='view' in rights
child.can_write_parent='add' in rights and 'modify' in rights
child.can_invite='manage_access' in rights
changes+=['can_view_parent','can_write_parent','can_invite']
for key in (() if 'permissions' in request.data else ('can_view_parent','can_write_parent','can_invite')):
if key in request.data:
if not isinstance(request.data[key],bool):return error('Les permissions doivent être des booléens.')
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))
if changes:child.save(update_fields=changes)
if 'is_active' in request.data and child.user_id is None:
return error('Ce dossier partagé ne possède pas de compte utilisateur.')
if 'is_active' in request.data:
if not isinstance(request.data['is_active'],bool):return error('Statut invalide.')
child.user.is_active=request.data['is_active']
child.user.save(update_fields=['is_active'])
return Response(space_info(child,request.user))
@api_view(['PATCH'])
def update_space_quota(request, space_id):
"""Administrators may cap any space; delegated inviters manage their direct children."""
with transaction.atomic():
try:space=DriveSpace.objects.select_related('parent','user').get(pk=space_id)
except DriveSpace.DoesNotExist:return error('Espace introuvable.',404)
if space.private_owner_id:
return error('Le quota des espaces privés est fixé à 50 Go.',403)
if not has_space_right(request.user,space,'manage_space'):
return error('Modification du quota interdite.',403)
if 'quota_bytes' not in request.data:return error('quota_bytes est obligatoire.')
try:_update_quota(space,request.data['quota_bytes'])
except ValueError as ex:return error(str(ex))
return Response(space_info(space,request.user))
@api_view(['POST'])
def create_invitation(request):
own=my_space(request.user)
parent=own
if request.data.get('parent_space'):
try:parent=DriveSpace.objects.get(pk=int(request.data['parent_space']))
except (ValueError,TypeError,DriveSpace.DoesNotExist):return error('Espace introuvable.',404)
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 not fields['can_view_parent']:
return error('L’invitation donne nécessairement accès en lecture à cet espace.')
try:
rights = assert_delegation(space_rights(request.user,parent),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(
write=fields['can_write_parent'],invite=fields['can_invite']))
fields['can_write_parent'] = 'add' in rights and 'modify' in rights
fields['can_invite'] = 'manage_access' in rights
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.')
# An invitation grants access. Storage limits are configured on spaces,
# not on invitation links (the private space always has its 50-Go cap).
token=secrets.token_urlsafe(32)
inv=SpaceInvitation.objects.create(parent_space=parent,created_by=request.user,
token_hash=hash_token(token),expires_at=timezone.now()+timedelta(days=days),permissions=rights,**fields)
# Returned exactly once; DB stores only SHA-256.
return Response({**invitation_info(inv),'token':token},status=201)
@api_view(['POST'])
def revoke_invitation(request,invitation_id):
try:inv=SpaceInvitation.objects.select_related('parent_space').get(pk=invitation_id)
except SpaceInvitation.DoesNotExist:return error('Invitation introuvable.',404)
if not manager(request.user,inv.parent_space):return error('Accès refusé.',403)
if inv.used_at:return error('Invitation déjà utilisée.')
inv.revoked_at=timezone.now();inv.save(update_fields=['revoked_at'])
return Response({'ok':True})
def get_valid_invitation(token,lock=False):
qs=SpaceInvitation.objects.select_related('parent_space', 'created_by')
if lock:qs=qs.select_for_update()
try:inv=qs.get(token_hash=hash_token(token))
except SpaceInvitation.DoesNotExist:return None
if inv.used_at or inv.revoked_at or inv.expires_at<=timezone.now():return None
# Revoking the inviter's delegation also invalidates their pending invitations.
if not manager(inv.created_by, inv.parent_space):return None
return inv
@api_view(['GET'])
@permission_classes([AllowAny])
@throttle_classes([AnonRateThrottle])
def invitation_preview(request,token):
inv=get_valid_invitation(token)
if inv is None:return error('Invitation invalide ou expirée.',404)
inviter = inv.created_by
inviter_name = inviter.get_full_name().strip() or inviter.get_username()
return Response({'parent_name':inv.parent_space.display_name,
'inviter_name':inviter_name,
'can_view_parent':inv.can_view_parent,
'can_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite,
'permissions':inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent, invite=inv.can_invite),
'expires_at':inv.expires_at.isoformat()})
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([AnonRateThrottle])
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','')
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:
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)
validate_password(password)
except ValidationError as ex:return error(' '.join(ex.messages))
if User.objects.filter(username__iexact=username).exists():return error('Ce nom d’utilisateur est déjà pris.')
if email and len(email)>254:return error('Adresse e-mail trop longue.')
try:
with transaction.atomic():
inv=get_valid_invitation(token,lock=True)
if inv is None:return error('Invitation invalide ou déjà utilisée.',404)
rights=inv.permissions if inv.permissions is not None else legacy_rights(write=inv.can_write_parent,invite=inv.can_invite)
assert_delegation(space_rights(inv.created_by,inv.parent_space),rights)
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,permissions=rights)
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)
# -----------------------------------------------------------------------------
# Direct access for existing accounts; never relocate their private files.
# -----------------------------------------------------------------------------
def grant_info(grant):
user = grant.user
return {'id': grant.pk, 'permissions': grant.permissions if grant.permissions is not None else legacy_rights(write=grant.can_write,invite=grant.can_invite), 'user_id': user.pk, 'username': user.username,
'first_name': user.first_name, 'last_name': user.last_name,
'can_write': grant.can_write, 'can_invite': grant.can_invite}
def managed_parent(request):
if request.query_params.get('parent_space'):
try:
return DriveSpace.objects.get(pk=int(request.query_params['parent_space']))
except (ValueError, TypeError, DriveSpace.DoesNotExist):
return None
return ensure_space(request.user)
@api_view(['GET'])
def search_users(request):
parent = managed_parent(request)
if parent is None or not manager(request.user, parent):
return error('Recherche non autorisée.', 403)
query = str(request.query_params.get('q', '')).strip()
if len(query) < 2:
return Response({'users': []})
User = get_user_model()
qs = User.objects.filter(username__icontains=query, is_active=True, is_staff=False, is_superuser=False)
qs = qs.exclude(pk=request.user.pk).order_by('username')[:10]
return Response({'users': [{'id':u.pk, 'username':u.username,
'first_name':u.first_name,'last_name':u.last_name}
for u in qs]})
@api_view(['POST'])
def add_existing_user(request):
parent = managed_parent(request)
if parent is None or not manager(request.user, parent):
return error('Ajout non autorisé.',403)
try:
user_id = int(request.data.get('user_id'))
except (TypeError, ValueError):
return error('Utilisateur invalide.')
User = get_user_model()
try:
account = User.objects.get(pk=user_id, is_active=True, is_staff=False, is_superuser=False)
except User.DoesNotExist:
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 = 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:
rights = assert_delegation(space_rights(request.user,parent),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(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': 'add' in rights and 'modify' in rights,'can_invite':'manage_access' in rights,'permissions':rights})
if not created:
return error('Cet utilisateur a déjà accès à cet espace.')
return Response(grant_info(grant),status=201)
@api_view(['PATCH','DELETE'])
def edit_existing_user(request, grant_id):
try:
grant = SpaceGrant.objects.select_related('space','user').get(pk=grant_id)
except SpaceGrant.DoesNotExist:
return error('Accès introuvable.',404)
if not manager(request.user,grant.space):
return error('Gestion non autorisée.',403)
if request.method == 'DELETE':
grant.delete()
return Response(status=204)
for key in ('can_write','can_invite'):
if key in request.data:
if not isinstance(request.data[key],bool):
return error('Permission invalide.')
setattr(grant,key,request.data[key])
try:
rights=assert_delegation(space_rights(request.user,grant.space),
request.data['permissions'] if 'permissions' in request.data else legacy_rights(write=grant.can_write,invite=grant.can_invite))
except ValueError as exc:return error(str(exc),403)
grant.permissions=rights
grant.can_write='add' in rights and 'modify' in rights
grant.can_invite='manage_access' in rights
grant.save(update_fields=['can_write','can_invite','permissions'])
return Response(grant_info(grant))
@api_view(['POST'])
def create_shared_space(request):
"""Create a standalone numbered shared space; no additional user is needed."""
try:
name=valid_name(request.data.get('display_name'))
if len(name)>120:return error('Nom limité à 120 caractères.')
quota=parse_quota(request.data.get('quota_bytes'))
parent_id=request.data.get('parent_space')
parent=DriveSpace.objects.get(pk=int(parent_id)) if parent_id else ensure_space(request.user)
if parent and quota is None and parent.quota_bytes is not None:quota=parent.quota_bytes
except (ValueError, TypeError) as exc:return error(str(exc))
except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404)
if not has_space_right(request.user,parent,'create_spaces'):return error('Création interdite dans cet espace.',403)
with transaction.atomic():
with locked_quota_tree(parent):
try:assert_new_child_quota(parent,quota)
except ValueError as exc:return error(str(exc))
space=_make_numbered_space(parent,managed_by=request.user,display_name=name,quota_bytes=quota)
return Response(space_info(space,request.user),status=201)
@api_view(['PATCH','DELETE'])
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 has_space_right(request.user,space,'manage_space'):
return error('Modification interdite.',403)
if request.method == 'DELETE':
return delete_shared_space(request, space_id)
try:
with transaction.atomic():
if 'display_name' in request.data:
title=valid_name(request.data['display_name'])
if len(title)>120:return error('Nom limité à 120 caractères.')
space.display_name=title
space.save(update_fields=['display_name'])
if 'quota_bytes' in request.data:
_update_quota(space,request.data['quota_bytes'])
except ValueError as exc:return error(str(exc))
return Response(space_info(space,request.user))
_logger = logging.getLogger(__name__)
def _remove_deleted_space_files(relative_path, legacy_locations, temp_ids):
"""Remove only the shared directory after the DB transaction commits."""
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
folder = (root / relative_path).resolve()
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
for location in legacy_locations:
path = (root / location).resolve()
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:
_logger.exception('Unable to remove legacy file: %s', path)
try:
if folder.exists():
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 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 _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)
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):
return error('Confirme la suppression en saisissant le nom exact de l’espace.')
with transaction.atomic():
try:
space = DriveSpace.objects.select_for_update().select_related('parent').get(pk=space_id)
except DriveSpace.DoesNotExist:
return error('Espace introuvable.', 404)
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 not has_space_right(request.user,space,'manage_space')):
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.')
# 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))
DriveItem.objects.filter(space_id__in=ids).delete()
# 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'])
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.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=[]
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})
@api_view(['GET'])
def admin_user_access(request, user_id):
"""Show only shared-space access, never a user's private storage."""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
try:
target = User.objects.get(pk=user_id, drive_archive__isnull=True)
except User.DoesNotExist:
return error('Utilisateur introuvable.', 404)
if is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
accesses = []
for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'):
accesses.append({'kind':'grant', 'space_id':grant.space_id, 'space_name':grant.space.display_name})
for space in DriveSpace.objects.filter(user=target, private_owner__isnull=True):
accesses.append({'kind':'owner', 'space_id':space.pk, 'space_name':space.display_name})
for space in DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True):
accesses.append({'kind':'manager', 'space_id':space.pk, 'space_name':space.display_name})
return Response({'accesses': accesses})
@api_view(['DELETE'])
def admin_revoke_user_access(request, user_id, kind, space_id):
"""Revoke a space membership, legacy assignment, or managed-space grant.
Never delete the shared space itself, its contents, or the user's private
space. Other independent grants remain intact.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
if kind not in ('grant', 'owner', 'manager'):
return error('Type d’accès inconnu.', 404)
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 is_admin(target):
return error('Impossible de modifier les accès d’un administrateur.', 403)
qs = DriveSpace.objects.select_for_update().filter(pk=space_id, private_owner__isnull=True)
if kind == 'grant':
deleted, _ = SpaceGrant.objects.filter(space__in=qs, user=target).delete()
if not deleted:return error('Accès introuvable.', 404)
elif kind == 'owner':
if not qs.filter(user=target).update(user=None):
return error('Accès introuvable.', 404)
else:
if not qs.filter(managed_by=target).update(managed_by=None):
return error('Accès introuvable.', 404)
return Response(status=204)
def _delete_staged_private(directory):
"""Physically remove a private directory after the SQL transaction commits."""
try:
if directory is not None and directory.exists():
shutil.rmtree(directory)
except OSError:
_logger.exception('Échec de suppression du dossier privé %s', directory)
def _delete_legacy_private_files(locations):
"""Remove only legacy UUID-key files stored directly at storage/ab/uuid."""
import uuid
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
for location in locations:
try:
parts = Path(location).parts
if len(parts) != 2 or len(parts[0]) != 2 or not all(c in '0123456789abcdef' for c in parts[0]):
continue
if str(uuid.UUID(parts[1])) != parts[1] or parts[0] != parts[1][:2]:
continue
# Never remove a legacy file still referenced by a shared item.
if DriveItem.objects.filter(storage_key=parts[1],storage_location='').exists():
continue
path = root.joinpath(*parts)
if path.is_symlink() or not path.resolve().is_relative_to(root):
continue
path.unlink(missing_ok=True)
except (ValueError, OSError):
_logger.exception('Impossible de supprimer le fichier privé historique : %s', location)
@api_view(['DELETE'])
def admin_delete_user(request, user_id):
"""Deactivate an account and PERMANENTLY remove its private files and space.
Historical shared files keep the archived owner row because their owner
FK is PROTECT; they are not removed by account deletion.
"""
if not is_admin(request.user):
return error('Accès réservé aux administrateurs.', 403)
User = get_user_model()
staged = None
original = None
try:
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.')
private = DriveSpace.objects.select_for_update().filter(private_owner=target).first()
legacy = []
if private:
# Never risk deleting an old shared directory when a private
# space has not yet been migrated to storage/privates/.
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
private_root = root / 'privates'
original = root / private.relative_path
if (not private.relative_path.startswith('privates/') or
original.parent != private_root or original.is_symlink() or
private_root.is_symlink() or not original.resolve().is_relative_to(private_root.resolve())):
return error('Déplace d’abord le stockage privé vers storage/privates.', 409)
if DriveSpace.objects.filter(parent=private).exists():
return error('Cet espace privé possède encore des sous-espaces.', 409)
# Legacy /ab/UUID paths are outside the numbered private folder.
for loc, key in DriveItem.objects.filter(space=private, is_folder=False).values_list('storage_location','storage_key'):
if key and not loc:
legacy.append(f'{key[:2]}/{key}')
if original.exists():
# Atomic rename to a location that is never browsable by
# another account. Roll back the rename on DB failure.
staged = private_root / f'.deleting-{private.pk}-{secrets.token_hex(12)}'
original.rename(staged)
# Deleting DriveItems removes their grants and public links.
DriveItem.objects.filter(space=private).delete()
UploadSession.objects.filter(space=private).delete()
private.delete()
now = timezone.now()
SpaceInvitation.objects.filter(created_by=target, used_at__isnull=True, revoked_at__isnull=True).update(revoked_at=now)
PublicItemLink.objects.filter(created_by=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()
# Shared spaces survive, but their former owner loses access.
DriveSpace.objects.filter(user=target, private_owner__isnull=True).update(user=None)
DriveSpace.objects.filter(managed_by=target, private_owner__isnull=True).update(managed_by=None)
unfinished = list(UploadSession.objects.filter(owner=target).values_list('pk',flat=True))
UploadSession.objects.filter(owner=target).delete()
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)
# Work only after a successful DB commit; TestCase can explicitly
# execute callbacks to validate the physical removal.
def complete_cleanup():
_delete_staged_private(staged)
_delete_legacy_private_files(legacy)
_remove_upload_parts(unfinished)
transaction.on_commit(complete_cleanup)
except Exception:
if staged is not None and staged.exists() and original is not None and not original.exists():
staged.rename(original)
raise
return Response(status=204)