GooDrive/backend/drive/storage_layout.py
Louis Mazin 26e0e05754 test
2026-10-14 18:57:05 +02:00

204 lines
8.4 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 cleanup_empty_trash_container(item):
"""Remove only this item's empty UUID container after restoration.
A non-empty directory is deliberately kept: it may contain unrelated data
and must never be removed recursively.
"""
container = disk_path(f'.gooddrive-trash/{item.pk}')
try:
container.rmdir()
except FileNotFoundError:
pass
except OSError:
# Non-empty or inaccessible: leave it intact. A later trash operation
# will refuse to overwrite any content that remains here.
pass
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.')
# Previous versions left the UUID container behind when restoring a file.
# Remove only empty legacy containers so the same item can be trashed again.
cleanup_empty_trash_container(item)
if target.parent.exists() or target.parent.is_symlink():
raise NameConflict('Le dossier de corbeille de cet élément contient encore des données.')
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