722 lines
37 KiB
Python
722 lines
37 KiB
Python
"""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 .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 {'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 manager(request.user,child.parent):return error('Modification interdite.',403)
|
||
changes=[]
|
||
for key in ('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 (is_admin(request.user) or (space.parent_id and not space.private_owner_id and manager(request.user,space.parent))):
|
||
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:enforce_delegation(request.user,parent,view=fields['can_view_parent'],write=fields['can_write_parent'],invite=fields['can_invite'])
|
||
except ValueError as exc:return error(str(exc),403)
|
||
try:days=int(request.data.get('expires_days',7))
|
||
except (ValueError,TypeError):return error('Durée invalide.')
|
||
if not 1<=days<=30:return error('La durée doit être comprise entre 1 et 30 jours.')
|
||
# 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),**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,
|
||
'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)
|
||
enforce_delegation(inv.created_by,inv.parent_space,view=True,
|
||
write=inv.can_write_parent,invite=inv.can_invite)
|
||
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
|
||
SpaceGrant.objects.create(user=user,space=inv.parent_space,
|
||
can_write=inv.can_write_parent,can_invite=inv.can_invite)
|
||
space=inv.parent_space
|
||
ensure_private_space(user)
|
||
inv.used_by=user;inv.used_at=timezone.now()
|
||
inv.save(update_fields=['used_by','used_at'])
|
||
except IntegrityError:return error('Le nom d’utilisateur est déjà utilisé.')
|
||
except ValueError as exc:return error(str(exc),403)
|
||
return Response({'ok':True,'username':user.username,'space_id':space.pk},status=201)
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# Direct access for existing accounts; never relocate their private files.
|
||
# -----------------------------------------------------------------------------
|
||
def grant_info(grant):
|
||
user = grant.user
|
||
return {'id': grant.pk, '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:enforce_delegation(request.user,parent,write=can_write,invite=can_invite)
|
||
except ValueError as exc:return error(str(exc),403)
|
||
with transaction.atomic():
|
||
grant,created = SpaceGrant.objects.select_for_update().get_or_create(
|
||
user=account, space=parent, defaults={'can_write': can_write,'can_invite':can_invite})
|
||
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:enforce_delegation(request.user,grant.space,write=grant.can_write,invite=grant.can_invite)
|
||
except ValueError as exc:return error(str(exc),403)
|
||
grant.save(update_fields=['can_write','can_invite'])
|
||
return Response(grant_info(grant))
|
||
|
||
|
||
@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)
|
||
except (ValueError, TypeError) as exc:return error(str(exc))
|
||
except DriveSpace.DoesNotExist:return error('Espace parent introuvable.',404)
|
||
if not manager(request.user,parent):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 (is_admin(request.user) or space.managed_by_id == request.user.pk or (space.user_id == request.user.pk and manager(request.user,space))):
|
||
return error('Modification interdite.',403)
|
||
if request.method == 'DELETE':
|
||
return delete_shared_space(request, space_id)
|
||
try:
|
||
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 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.')
|
||
# 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)
|