GooDrive/backend/drive/views_spaces.py
Louis Mazin 9834626cf4 nice
2026-10-14 17:57:24 +02:00

895 lines
47 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.db import models
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, can_change_space_quota
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:
if not can_change_space_quota(request.user, child):
return error('Seul un administrateur peut modifier le quota attribué à cet espace.',403)
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):
"""Admins can adjust allocations; others only their own created subspaces."""
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 can_change_space_quota(request.user,space):
return error('Seul un administrateur peut modifier le quota attribué à cet espace.',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)
def _relocate_shared_space(user, space, target_parent_id):
"""Change an entire shared-space subtree's physical and logical parent.
Called with a DB transaction and all DriveSpace rows locked. Database
changes happen before the single same-filesystem directory rename; caller
reverses the rename if the transaction fails.
"""
from .quotas import ancestry, occupancy, subtree_space_ids
from .storage_layout import disk_path
if space.private_owner_id or space.parent_id is None:
raise ValueError('L’espace racine et les espaces privés ne peuvent pas changer de parent.')
try:
target = DriveSpace.objects.get(pk=int(target_parent_id), private_owner__isnull=True)
except (TypeError, ValueError, DriveSpace.DoesNotExist):
raise ValueError('Espace parent introuvable.') from None
if target.pk == space.parent_id:
return None # No permission required to retain the existing parent.
if not has_space_right(user, space, 'manage_space') or not has_space_right(user, target, 'create_spaces'):
raise PermissionError('Tu dois pouvoir gérer cet espace et créer des espaces dans le nouveau parent.')
descendants = subtree_space_ids(space)
if target.pk in descendants:
raise ValueError('Impossible de déplacer un espace dans lui-même ou dans un de ses sous-espaces.')
if target.private_owner_id:
raise ValueError('Un espace privé ne peut pas être parent.')
old_prefix = space.relative_path
new_prefix = '/'.join(filter(None, [target.relative_path, space.directory_name]))
if not old_prefix or not space.directory_name or len(new_prefix) > 700:
raise ValueError('Déplacement impossible pour cet espace.')
# All the actual files and pending uploads of this subtree must fit in
# each NEW ancestor. Common ancestors already include that usage.
from .quotas import ancestry, occupancy
subtree_used, subtree_pending = occupancy(space)
former_ancestors = {node.pk for node in ancestry(space)}
for ancestor in ancestry(target):
if ancestor.pk in former_ancestors or ancestor.quota_bytes is None:
continue
used, pending = occupancy(ancestor)
if used + pending + subtree_used + subtree_pending > ancestor.quota_bytes:
raise ValueError('Le nouvel espace parent n’a pas assez de capacité disponible.')
caps = [node.quota_bytes for node in ancestry(target) if node.quota_bytes is not None]
max_allowed = min(caps) if caps else None
if max_allowed is not None:
if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True).filter(
models.Q(quota_bytes__isnull=True) | models.Q(quota_bytes__gt=max_allowed)
).exists():
raise ValueError('Le quota d’un sous-espace dépasse celui du nouveau parent.')
# Refuse to move mid-upload: it can change the path during a file write.
if UploadSession.objects.filter(space_id__in=descendants).exists():
raise ValueError('Termine ou annule les transferts en cours avant de changer le parent.')
if DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=False).exclude(
relative_path__startswith='privates/').exists():
raise ValueError('Déplace d’abord les anciens espaces privés vers storage/privates/.')
source = disk_path(old_prefix)
destination = disk_path(new_prefix)
if not source.is_dir() or source.is_symlink():
raise ValueError('Le dossier de l’espace est introuvable ou invalide.')
if destination.exists() or destination.is_symlink():
raise ValueError('Un dossier existe déjà à cet emplacement.')
if not destination.parent.is_dir():
raise ValueError('Le répertoire du nouveau parent est introuvable.')
# Include all nested shared spaces, but NEVER touch storage/privates/.
affected = list(DriveSpace.objects.filter(pk__in=descendants, private_owner__isnull=True))
prefix = old_prefix + '/'
for node in affected:
if node.pk != space.pk and not node.relative_path.startswith(prefix):
raise ValueError('Incohérence entre l’arborescence et le stockage physique.')
updated = new_prefix + node.relative_path[len(old_prefix):]
if len(updated) > 700:
raise ValueError('Hiérarchie trop profonde pour ce déplacement.')
node.relative_path = updated
# A rollback will restore all of these SQL updates, including the item paths.
for node in affected:
node.save(update_fields=['relative_path'])
space.parent = target
# Legacy assigned spaces could implicitly pass permissions to their parent.
# Moving them must not silently grant those rights on a DIFFERENT parent.
if space.user_id is not None:
space.can_view_parent = False
space.can_write_parent = False
space.can_invite = False
space.parent_permissions = None
space.save(update_fields=['parent','can_view_parent','can_write_parent','can_invite','parent_permissions'])
else:
space.save(update_fields=['parent'])
# Modern readable-storage keys follow the physical directory; legacy
# UUID locations outside this subtree remain untouched.
items = DriveItem.objects.filter(space_id__in=[node.pk for node in affected],
storage_location__startswith=prefix)
# Include files stored directly under old_prefix as well.
for item in items.iterator(chunk_size=500):
item.storage_location = new_prefix + item.storage_location[len(old_prefix):]
item.save(update_fields=['storage_location'])
source.rename(destination) # Atomic on this filesystem.
return source, destination
@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)
moved = None
try:
with transaction.atomic():
# Prevent concurrent moves/creation or quota alterations from
# observing an incomplete hierarchy.
list(DriveSpace.objects.select_for_update().order_by('pk'))
space = DriveSpace.objects.select_related('parent','managed_by').get(pk=space_id)
if 'parent_space' in request.data:
moved = _relocate_shared_space(request.user, space, request.data['parent_space'])
if 'quota_bytes' in request.data:
if not can_change_space_quota(request.user, space):
raise PermissionError('Seul un administrateur peut modifier le quota attribué à cet espace.')
_update_quota(space,request.data['quota_bytes'])
if 'display_name' in request.data:
title=valid_name(request.data['display_name'])
if len(title)>120:raise ValueError('Nom limité à 120 caractères.')
space.display_name=title
space.save(update_fields=['display_name'])
except Exception as exc:
if moved is not None:
source, destination = moved
try:
destination.rename(source)
except OSError:
_logger.exception('ALERTE: impossible de restaurer un espace après un échec SQL: %s', source)
return error('Déplacement interrompu : intervention manuelle nécessaire.',500)
if isinstance(exc,PermissionError):return error(str(exc),403)
if isinstance(exc,(ValueError,OSError)):return error(str(exc),409 if isinstance(exc,OSError) else 400)
raise
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 = []
role_spaces = set()
for kind, role_filter in [('owner', {'user':target}), ('manager', {'managed_by':target})]:
for space in DriveSpace.objects.filter(private_owner__isnull=True, **role_filter):
if space.pk in role_spaces:
continue
role_spaces.add(space.pk)
accesses.append({'kind':kind, 'space_id':space.pk,
'space_name':space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, space)]})
for grant in SpaceGrant.objects.filter(user=target, space__private_owner__isnull=True).select_related('space'):
if grant.space_id not in role_spaces:
accesses.append({'kind':'grant', 'space_id':grant.space_id,
'space_name':grant.space.display_name, 'permissions':[right for right in RIGHTS if right in space_rights(target, grant.space)]})
return Response({'accesses': accesses})
@api_view(['DELETE','PATCH'])
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 request.method == 'PATCH':
space = qs.first()
if space is None:
return error('Espace introuvable.',404)
if kind == 'owner' and space.user_id != target.pk or kind == 'manager' and space.managed_by_id != target.pk:
return error('Accès introuvable.',404)
if kind == 'grant' and not SpaceGrant.objects.filter(space=space,user=target).exists():
return error('Accès introuvable.',404)
try:
rights = normalized(request.data.get('permissions'))
except ValueError as exc:
return error(str(exc))
grant, _ = SpaceGrant.objects.select_for_update().get_or_create(user=target,space=space)
grant.permissions=rights
grant.can_write='add' in rights and 'modify' in rights
grant.can_invite='manage_access' in rights
grant.save(update_fields=['permissions','can_write','can_invite'])
return Response({'space_id':space.pk,'permissions':rights})
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)
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
else:
if not qs.filter(managed_by=target).update(managed_by=None):
return error('Accès introuvable.', 404)
SpaceGrant.objects.filter(space_id=space_id,user=target).delete()
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)