891 lines
47 KiB
Python
891 lines
47 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.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
|
||
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)
|
||
|
||
|
||
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:
|
||
_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)
|