GooDrive/backend/drive/storage_layout.py
Louis Mazin 6e1ca588b7 test
2026-10-14 02:00:36 +02:00

115 lines
4.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):
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