GooDrive/backend/drive/views_spaces.py
Louis Mazin c11c661897 test
2026-10-14 15:58:46 +02:00

485 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Invitation-only registration and hierarchical space management."""
import secrets
import logging
import shutil
from pathlib import Path
from django.conf import settings
from django.db.models import Q
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.db import transaction, IntegrityError
from django.utils import timezone
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.throttling import AnonRateThrottle
from rest_framework.response import Response
from .models import DriveSpace, SpaceInvitation, SpaceGrant, DriveItem, UploadSession
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.user_id == request.user.pk and own.parent_id and own.can_view_parent and not own.parent.private_owner_id:
permitted.append(own.parent)
permitted.extend(space for space in qs.filter(access_grants__user=request.user) if not space.private_owner_id)
permitted.extend(qs.filter(managed_by=request.user,private_owner__isnull=True))
permitted=list({space.pk:space for space in permitted}.values())
return Response({'mine':private.pk, 'assigned':own.pk, 'private':private.pk,
'spaces':[space_info(space,request.user) for space in permitted]})
@api_view(['PATCH'])
def rename_my_space(request):
own=my_space(request.user)
if own.user_id != request.user.pk and not is_admin(request.user):
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]
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 SpaceGrant.objects.filter(space=parent).select_related('user').order_by('user__username')],
'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):
"""Executed only AFTER commit: keep SQL changes atomic before touching disk."""
root = Path(settings.DRIVE_STORAGE_ROOT).resolve()
folder = (root / relative_path).resolve()
if folder == root or not folder.is_relative_to(root):
_logger.error('Invalid space path for disk cleanup: %s', relative_path)
return
# Old files may predate the numbered storage directories.
for location in legacy_locations:
path = (root / location).resolve()
if path.is_relative_to(root) and not path.is_relative_to(folder):
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)
temporary_root = Path(settings.FILE_UPLOAD_TEMP_DIR).resolve()
for upload_id in temp_ids:
try:
(temporary_root / f'drive-{upload_id}.part').unlink(missing_ok=True)
except OSError:
_logger.exception('Unable to remove canceled upload %s', upload_id)
def delete_shared_space(request, space_id):
"""Permanently delete a manually managed space and its independent descendants.
Never delete root, assigned account spaces, or ANY private space, including a
private descendant. All cleanup is committed before disk deletion starts.
"""
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 not space.parent_id or space.user_id or space.private_owner_id or not space.managed_by_id:
return error('Les espaces personnels, attribués et racine ne peuvent pas être supprimés.', 403)
if not manager(request.user, space):
return error('Suppression interdite.', 403)
if confirm_name != space.display_name:
return error('Le nom de confirmation ne correspond pas au nom de l’espace.')
# Also lock the parent: creation and deletion of siblings are serialized.
list(DriveSpace.objects.select_for_update().filter(pk=space.parent_id))
descendants = list(DriveSpace.objects.select_for_update().filter(
Q(relative_path=space.relative_path) |
Q(relative_path__startswith=space.relative_path + '/')
).order_by('-relative_path'))
if any(child.user_id or child.private_owner_id or not child.managed_by_id for child in descendants):
return error('Cet espace contient un espace attribué ou privé. Réorganise les sous-utilisateurs avant de le supprimer.', 409)
ids = [child.pk for child in descendants]
files = list(DriveItem.objects.filter(space_id__in=ids, is_folder=False).values_list(
'storage_location', 'storage_key'))
legacy_locations = [location or f'{key[:2]}/{key}'
for location, key in files if key and not location]
upload_ids = list(UploadSession.objects.select_for_update().filter(
space_id__in=ids).values_list('pk', flat=True))
# Children of DriveItem use CASCADE; item ownership and other accounts
# are left intact, only metadata from these owned spaces is deleted.
DriveItem.objects.filter(space_id__in=ids).delete()
for child in descendants:
child.delete() # bottom-up, as DriveSpace.parent uses PROTECT
transaction.on_commit(lambda: _remove_deleted_space_files(
space.relative_path, legacy_locations, upload_ids))
return Response({'deleted_space_ids': ids})
@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.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})