diff --git a/backend/drive/spaces.py b/backend/drive/spaces.py index 4147278..416921d 100644 --- a/backend/drive/spaces.py +++ b/backend/drive/spaces.py @@ -35,12 +35,33 @@ def ensure_space(user): def _make_numbered_space(parent, **fields): if len(parent.relative_path) + 1 + 24 > 700: raise ValueError('Hiérarchie trop profonde.') - obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields) - obj.directory_name = f'{obj.pk - 1:04d}' - obj.relative_path = '/'.join(part for part in (parent.relative_path, obj.directory_name) if part) - obj.save(update_fields=['directory_name', 'relative_path']) - (Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True) - return obj + with transaction.atomic(): + obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields) + number = obj.pk - 1 + directory = Path(settings.DRIVE_STORAGE_ROOT) / parent.relative_path + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + # With real file names, a user may already have created "0001" as a + # folder/file. Do NOT reuse it for another person's space. + for _ in range(100000): + candidate = f'{number:04d}' + target = directory / candidate + if DriveSpace.objects.filter(directory_name=candidate).exists() or target.exists() or target.is_symlink(): + number += 1 + continue + try: + target.mkdir(mode=0o700) + except FileExistsError: + number += 1 + continue + try: + obj.directory_name = candidate + obj.relative_path = '/'.join(part for part in (parent.relative_path, candidate) if part) + obj.save(update_fields=['directory_name', 'relative_path']) + except Exception: + target.rmdir() + raise + return obj + raise ValueError('Aucun numéro de répertoire disponible pour ce nouvel espace.') def create_space(user, parent, display_name, **permissions): @@ -161,9 +182,6 @@ def storage_path(item): return resolved -def new_storage_location(space, key): - return '/'.join(part for part in (space.relative_path, key[:2], key) if part) - def hash_token(token): return hashlib.sha256(token.encode('utf-8')).hexdigest() diff --git a/backend/drive/storage_layout.py b/backend/drive/storage_layout.py new file mode 100644 index 0000000..7fe1a07 --- /dev/null +++ b/backend/drive/storage_layout.py @@ -0,0 +1,114 @@ +"""Store DriveItems as normal filenames beneath their numbered DriveSpace. + +All relative paths are inside DRIVE_STORAGE_ROOT. The DB hierarchy is authoritative, +so a physical folder can be rebuilt from its current DriveItem ancestors. +""" +from pathlib import Path + +from django.conf import settings +from django.db import transaction + +from .models import DriveItem + + +class NameConflict(ValueError): + pass + + +def folder_location(space, parent): + parts = [] + seen = set() + while parent is not None: + if parent.pk in seen or not parent.is_folder or parent.space_id != space.pk: + raise ValueError('Hiérarchie de dossiers invalide.') + if parent.name in ('.', '..') or '/' in parent.name or '\\' in parent.name: + raise ValueError('Nom de dossier invalide.') + seen.add(parent.pk) + parts.append(parent.name) + parent = parent.parent + return '/'.join(part for part in (space.relative_path, *reversed(parts)) if part) + + +def file_location(space, parent, name): + return '/'.join(part for part in (folder_location(space, parent), name) if part) + + +def disk_path(location): + root = Path(settings.DRIVE_STORAGE_ROOT).resolve() + path = root + for part in Path(location).parts: + if part in ('', '.', '..') or part.startswith('/') or '\\' in part: + raise ValueError('Chemin de stockage invalide.') + path = path / part + if path.is_symlink(): + raise ValueError('Lien symbolique interdit dans le stockage.') + if not path.resolve().is_relative_to(root): + raise ValueError('Chemin de stockage hors du répertoire autorisé.') + return path + + +def assert_available(space, parent, name, exclude=None): + siblings = DriveItem.objects.filter(space=space, parent=parent, name=name) + if exclude is not None: + siblings = siblings.exclude(pk=exclude) + if siblings.exists(): + raise NameConflict('Un élément portant ce nom existe déjà à cet emplacement (y compris dans la corbeille).') + target = disk_path(file_location(space, parent, name)) + if target.exists() or target.is_symlink(): + raise NameConflict('Un fichier ou un dossier portant ce nom existe déjà sur le disque.') + return target + + +def prepare_parent(space, parent): + location = folder_location(space, parent) + destination = disk_path(location) + destination.mkdir(parents=True, exist_ok=True) + return destination + + +def relocate_item(item, new_parent, new_name): + """Rename/move a file or folder and its descendants on disk and in SQL. + + The on-disk move is reversed if the database transaction fails. The caller + must ensure authorization and that new_parent is not a descendant of item. + """ + old = (file_location(item.space, item.parent, item.name) if item.is_folder + else item.storage_location or f'{item.storage_key[:2]}/{item.storage_key}') + new = file_location(item.space, new_parent, new_name) + source = disk_path(old) + target = assert_available(item.space, new_parent, new_name, exclude=item.pk) + prepare_parent(item.space, new_parent) + if not source.exists(): + if not item.is_folder: + raise FileNotFoundError(f'Fichier source introuvable : {old}') + # Historical logical folders had no corresponding physical directory. + source.mkdir(parents=True, exist_ok=True) + if (not source.is_dir()) if item.is_folder else (not source.is_file()): + raise ValueError('Type de fichier incorrect sur le disque.') + moved = False + try: + with transaction.atomic(): + # Two distinct paths are guaranteed by caller's early unchanged check. + source.rename(target) + moved = True + if item.is_folder: + prefix = old.rstrip('/') + '/' + affected = list(DriveItem.objects.filter( + space=item.space, storage_location__startswith=prefix + ).only('pk', 'storage_location')) + for child in affected: + child.storage_location = new + '/' + child.storage_location[len(prefix):] + if affected: + DriveItem.objects.bulk_update(affected, ['storage_location'], batch_size=500) + else: + item.storage_location = new + item.name = new_name + item.parent = new_parent + item.full_clean() + item.save() + except Exception: + if moved: + # Do not replace a path created by another operation in the meantime. + if target.exists() and not source.exists(): + target.rename(source) + raise diff --git a/backend/drive/views_items.py b/backend/drive/views_items.py index 798d2a8..ad8e304 100644 --- a/backend/drive/views_items.py +++ b/backend/drive/views_items.py @@ -11,6 +11,7 @@ from .models import DriveItem, DriveSpace from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space from .quotas import quota_state from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files +from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict @api_view(['GET']) @@ -87,8 +88,21 @@ def create_folder(request): parent=target_parent(request.data.get('parent'),request.user,space) except ValueError as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) - folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True) - folder.full_clean();folder.save() + with transaction.atomic(): + try: + destination=assert_available(space,parent,name) + prepare_parent(space,parent) + destination.mkdir(mode=0o700) + except (NameConflict,FileExistsError) as exc: + return error(str(exc),409) + except OSError as exc: + return error(f'Création impossible : {exc}') + folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True) + try: + folder.full_clean();folder.save() + except Exception: + destination.rmdir() + raise return Response(item_info(folder,request.user),status=201) @@ -98,22 +112,26 @@ def update_item(request,item_id): if not readable(item,request.user) or not writable(item,request.user): return error('Modification interdite.',403) data=request.data - if 'name' in data: - try:item.name=valid_name(data['name']) - except ValueError as ex:return error(str(ex)) - if 'is_shared' in data:return error('Le partage global est désactivé : utilise les espaces et invitations.') - if 'parent' in data: - try:new_parent=target_parent(data['parent'],request.user,item.space) - except ValueError as ex:return error(str(ex)) - except PermissionError as ex:return error(str(ex),403) - if new_parent and (new_parent.id==item.id or item.id in [n.id for n in chain(new_parent)]): - return error('Déplacement impossible dans un sous-dossier de cet élément.') - item.parent=new_parent - try:item.full_clean() - except ValidationError as ex:return error(str(ex)) - item.save() - return Response(item_info(item,request.user)) + try: + new_name=valid_name(data['name']) if 'name' in data else item.name + new_parent=(target_parent(data['parent'],request.user,item.space) + if 'parent' in data else item.parent) + except ValueError as exc:return error(str(exc)) + except PermissionError as exc:return error(str(exc),403) + if 'is_shared' in data: + return error('Le partage global est désactivé : utilise les espaces et invitations.') + if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]): + return error('Déplacement impossible dans un sous-dossier de cet élément.') + if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id: + return Response(item_info(item,request.user)) + try: + relocate_item(item,new_parent,new_name) + except (NameConflict,FileExistsError) as exc: + return error(str(exc),409) + except (OSError,ValueError,ValidationError) as exc: + return error(f'Déplacement impossible : {exc}',409) + return Response(item_info(item,request.user)) @api_view(['POST']) def trash_item(request,item_id): @@ -130,7 +148,10 @@ def restore_item(request,item_id): if not writable(item,request.user):return error('Accès refusé.',403) if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.') if item.parent and any(n.trashed_at for n in chain(item.parent)): - item.parent=None + try: + relocate_item(item,None,item.name) + except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc: + return error(f'Restauration impossible : {exc}',409) item.trashed_at=None;item.save() return Response(item_info(item,request.user)) @@ -141,6 +162,7 @@ def delete_item(request,item_id): if not writable(item,request.user):return error('Accès refusé.',403) if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.') files=descendant_files(item) + folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None with transaction.atomic(): item.delete() def remove_files(): @@ -148,5 +170,15 @@ def delete_item(request,item_id): for key in files: path=(root/key).resolve() if path.is_relative_to(root):path.unlink(missing_ok=True) + if folder_rel: + # Remove only empty folders. Untracked data is never deleted. + base=disk_path(folder_rel) + if base.is_dir(): + for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True): + if candidate.is_dir() and not candidate.is_symlink(): + try:candidate.rmdir() + except OSError:pass + try:base.rmdir() + except OSError:pass transaction.on_commit(remove_files) return Response(status=204) diff --git a/backend/drive/views_transfers.py b/backend/drive/views_transfers.py index 5e4f00d..504e114 100644 --- a/backend/drive/views_transfers.py +++ b/backend/drive/views_transfers.py @@ -1,6 +1,5 @@ """Regular and resumable upload endpoints (disk-backed).""" import mimetypes -import os import uuid from pathlib import Path from django.conf import settings @@ -11,7 +10,8 @@ from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.response import Response from .models import DriveItem, UploadSession from .services import error, item_info, target_parent, valid_name, readable, writable -from .spaces import resolve_space, can_write_space, new_storage_location +from .spaces import resolve_space, can_write_space +from .storage_layout import file_location, disk_path, assert_available, prepare_parent, NameConflict from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity @@ -39,13 +39,13 @@ def upload(request): for f in incoming: name=valid_name(f.name) key=str(uuid.uuid4()) - location=new_storage_location(space,key) - dst=Path(settings.DRIVE_STORAGE_ROOT)/location - dst.parent.mkdir(parents=True,exist_ok=True) + location=file_location(space,parent,name) + dst=assert_available(space,parent,name) + prepare_parent(space,parent) with open(dst,'xb') as out: + saved.append(dst) # Cleanup even when a stream fails halfway. for block in f.chunks(chunk_size=2*1024*1024): out.write(block) - saved.append(dst) mime=mimetypes.guess_type(name)[0] or 'application/octet-stream' node=DriveItem.objects.create(owner=request.user,space=space,parent=parent,name=name, storage_key=key,storage_location=location,size=f.size,mime_type=mime) @@ -54,7 +54,7 @@ def upload(request): for entry in saved: path=entry[0] if isinstance(entry,tuple) else entry path.unlink(missing_ok=True) - return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(f'Import impossible : {ex}') + return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(str(ex),409) if isinstance(ex,(NameConflict,FileExistsError)) else error(f'Import impossible : {ex}') except Exception: for entry in saved: path=entry[0] if isinstance(entry,tuple) else entry @@ -74,6 +74,9 @@ def upload_start(request): except (TypeError,ValueError) as ex:return error(str(ex)) except PermissionError as ex:return error(str(ex),403) if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413) + try: + assert_available(space,parent,name) + except NameConflict as ex: return error(str(ex),409) with transaction.atomic(): with locked_quota_tree(space): if UploadSession.objects.filter(owner=request.user).count()>=20: @@ -131,17 +134,31 @@ def upload_finish(request,upload_id): if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))): return error('Dossier de destination indisponible.',403) key=str(uuid.uuid4()) - location=new_storage_location(job.space,key) - dst=Path(settings.DRIVE_STORAGE_ROOT)/location - dst.parent.mkdir(parents=True,exist_ok=True) - os.replace(src,dst) + location=file_location(job.space,job.parent,job.name) + created=False + try: + dst=assert_available(job.space,job.parent,job.name) + prepare_parent(job.space,job.parent) + # Exclusive creation: never overwrite a file already on disk. + # Copy, rather than rename: the temporary directory can be another volume. + with src.open('rb') as source, dst.open('xb') as output: + created=True + import shutil + shutil.copyfileobj(source,output,2*1024*1024) + except (NameConflict,FileExistsError) as exc: + if created:dst.unlink(missing_ok=True) + return error(str(exc),409) + except OSError as exc: + if created:dst.unlink(missing_ok=True) + return error(f'Finalisation impossible : {exc}') mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream' try: node=DriveItem.objects.create(owner=request.user,space=job.space,parent=job.parent,name=job.name, size=job.total_size,mime_type=mime,storage_key=key,storage_location=location) job.delete() + transaction.on_commit(lambda: src.unlink(missing_ok=True)) except Exception: - os.replace(dst,src) + dst.unlink(missing_ok=True) raise return Response(item_info(node,request.user),status=201)