"""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): # Trashed entries have their own isolated physical location. Names can be reused. siblings = DriveItem.objects.filter(space=space, parent=parent, name=name, trashed_at__isnull=True) 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 = item.storage_location or (file_location(item.space, item.parent, item.name) if item.is_folder else 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: _update_descendant_locations(item, old, new) 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 def _update_descendant_locations(item, old, new): """Keep physical paths in sync, including legacy folders without a stored path. Entries already moved independently to trash keep their own location. """ old_prefix = old.rstrip('/') + '/' new_prefix = new.rstrip('/') + '/' pending = [item.pk] while pending: batch, pending = pending[:300], pending[300:] children = list(DriveItem.objects.filter(parent_id__in=batch).select_related('parent', 'space')) pending.extend(child.pk for child in children if child.is_folder) changed = [] for child in children: location = child.storage_location if not location and child.is_folder: # Historical folders used only their logical name as a disk path. location = file_location(child.space, child.parent, child.name) if location and location.startswith(old_prefix): child.storage_location = new_prefix + location[len(old_prefix):] changed.append(child) if changed: DriveItem.objects.bulk_update(changed, ['storage_location'], batch_size=300) def trash_location(item): """Each deleted object gets a separate directory, regardless of display name.""" return f'.gooddrive-trash/{item.pk}/{item.name}' def move_to_trash(item, *, migrate_existing=False): """Atomic disk/SQL move. Preserve the original name/parent for restoration.""" from django.utils import timezone from .trash_cleanup import has_active_uploads_in_tree if (item.trashed_at is not None) != migrate_existing: raise ValueError('État de corbeille inattendu.') if migrate_existing and item.storage_location == trash_location(item): return item if has_active_uploads_in_tree(item): raise ValueError('Un transfert est encore en cours dans ce dossier.') old = item.storage_location or (file_location(item.space, item.parent, item.name) if item.is_folder else f'{item.storage_key[:2]}/{item.storage_key}') new = trash_location(item) source, target = disk_path(old), disk_path(new) if not source.exists() or (item.is_folder != source.is_dir()): raise FileNotFoundError('Élément absent du stockage, déplacement impossible.') if target.exists() or target.is_symlink(): raise NameConflict('La destination de la corbeille est déjà utilisée.') target.parent.mkdir(parents=True, exist_ok=False) moved = False try: with transaction.atomic(): source.rename(target) moved = True if item.is_folder: _update_descendant_locations(item, old, new) item.storage_location = new if migrate_existing: item.save(update_fields=['storage_location']) else: item.trashed_at = timezone.now() item.save(update_fields=['storage_location', 'trashed_at', 'updated_at']) except Exception: if moved and target.exists() and not source.exists(): target.rename(source) raise finally: # Leave the isolation folder only while it contains the deleted element. if not target.exists(): try: target.parent.rmdir() except OSError: pass return item