"""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