182 lines
7.5 KiB
Python
182 lines
7.5 KiB
Python
"""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
|