GooDrive/backend/drive/views_spaces.py
Louis Mazin ef5b14d084 nice
2026-10-14 00:08:24 +02:00

442 lines
22 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_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):
# Administrators can manage any descendant; delegates their own direct children only.
return can_manage_space(user, parent)
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,'quota_bytes':inv.quota_bytes}
@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.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)
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."""
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.')
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 and space.private_owner_id != request.user.pk:
return error('Cet espace personnel est privé.',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 fields['can_write_parent'] and not fields['can_view_parent']:
return error('L’écriture nécessite la lecture de l’espace parent.')
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.')
try:quota=parse_quota(request.data.get('quota_bytes'))
except ValueError as ex:return error(str(ex))
with transaction.atomic():
with locked_quota_tree(parent):
try:assert_new_child_quota(parent,quota)
except ValueError as exc:return error(str(exc))
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),quota_bytes=quota,**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')
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)
return Response({'parent_name':inv.parent_space.display_name,
'can_view_parent':inv.can_view_parent,
'can_write_parent':inv.can_write_parent,
'can_invite':inv.can_invite,
'quota_bytes':inv.quota_bytes,
'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','')
display_name=str(request.data.get('display_name','')).strip()
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 or not display_name or len(display_name)>120:
return error('Nom d’utilisateur et nom d’espace obligatoires (150/120 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)
with locked_quota_tree(inv.parent_space):
try:assert_new_child_quota(inv.parent_space,inv.quota_bytes)
except ValueError as exc:return error(str(exc))
user=User.objects.create_user(username=username,password=password,email=email,first_name=first_name,last_name=last_name)
space=create_space(user,inv.parent_space,display_name,
can_view_parent=inv.can_view_parent,can_write_parent=inv.can_write_parent,
can_invite=inv.can_invite,quota_bytes=inv.quota_bytes)
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é.')
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 = ensure_space(account)
if 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.')
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])
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 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})