This commit is contained in:
Louis Mazin 2026-10-14 02:00:36 +02:00
parent 8034138cc7
commit 6e1ca588b7
4 changed files with 220 additions and 39 deletions

View File

@ -35,12 +35,33 @@ def ensure_space(user):
def _make_numbered_space(parent, **fields):
if len(parent.relative_path) + 1 + 24 > 700:
raise ValueError('Hiérarchie trop profonde.')
obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields)
obj.directory_name = f'{obj.pk - 1:04d}'
obj.relative_path = '/'.join(part for part in (parent.relative_path, obj.directory_name) if part)
obj.save(update_fields=['directory_name', 'relative_path'])
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
return obj
with transaction.atomic():
obj = DriveSpace.objects.create(parent=parent, relative_path=f'pending/{secrets.token_hex(10)}', **fields)
number = obj.pk - 1
directory = Path(settings.DRIVE_STORAGE_ROOT) / parent.relative_path
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
# With real file names, a user may already have created "0001" as a
# folder/file. Do NOT reuse it for another person's space.
for _ in range(100000):
candidate = f'{number:04d}'
target = directory / candidate
if DriveSpace.objects.filter(directory_name=candidate).exists() or target.exists() or target.is_symlink():
number += 1
continue
try:
target.mkdir(mode=0o700)
except FileExistsError:
number += 1
continue
try:
obj.directory_name = candidate
obj.relative_path = '/'.join(part for part in (parent.relative_path, candidate) if part)
obj.save(update_fields=['directory_name', 'relative_path'])
except Exception:
target.rmdir()
raise
return obj
raise ValueError('Aucun numéro de répertoire disponible pour ce nouvel espace.')
def create_space(user, parent, display_name, **permissions):
@ -161,9 +182,6 @@ def storage_path(item):
return resolved
def new_storage_location(space, key):
return '/'.join(part for part in (space.relative_path, key[:2], key) if part)
def hash_token(token):
return hashlib.sha256(token.encode('utf-8')).hexdigest()

View File

@ -0,0 +1,114 @@
"""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

View File

@ -11,6 +11,7 @@ from .models import DriveItem, DriveSpace
from .spaces import ensure_space, ensure_private_space, is_admin, resolve_space, can_write_space, can_read_space
from .quotas import quota_state
from .services import error, item_info, get_item, chain, readable, writable, valid_name, target_parent, descendant_files
from .storage_layout import disk_path, file_location, assert_available, prepare_parent, relocate_item, NameConflict
@api_view(['GET'])
@ -87,8 +88,21 @@ def create_folder(request):
parent=target_parent(request.data.get('parent'),request.user,space)
except ValueError as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True)
folder.full_clean();folder.save()
with transaction.atomic():
try:
destination=assert_available(space,parent,name)
prepare_parent(space,parent)
destination.mkdir(mode=0o700)
except (NameConflict,FileExistsError) as exc:
return error(str(exc),409)
except OSError as exc:
return error(f'Création impossible : {exc}')
folder=DriveItem(owner=request.user,space=space,parent=parent,name=name,is_folder=True)
try:
folder.full_clean();folder.save()
except Exception:
destination.rmdir()
raise
return Response(item_info(folder,request.user),status=201)
@ -98,22 +112,26 @@ def update_item(request,item_id):
if not readable(item,request.user) or not writable(item,request.user):
return error('Modification interdite.',403)
data=request.data
if 'name' in data:
try:item.name=valid_name(data['name'])
except ValueError as ex:return error(str(ex))
if 'is_shared' in data:return error('Le partage global est désactivé : utilise les espaces et invitations.')
if 'parent' in data:
try:new_parent=target_parent(data['parent'],request.user,item.space)
except ValueError as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
if new_parent and (new_parent.id==item.id or item.id in [n.id for n in chain(new_parent)]):
return error('Déplacement impossible dans un sous-dossier de cet élément.')
item.parent=new_parent
try:item.full_clean()
except ValidationError as ex:return error(str(ex))
item.save()
return Response(item_info(item,request.user))
try:
new_name=valid_name(data['name']) if 'name' in data else item.name
new_parent=(target_parent(data['parent'],request.user,item.space)
if 'parent' in data else item.parent)
except ValueError as exc:return error(str(exc))
except PermissionError as exc:return error(str(exc),403)
if 'is_shared' in data:
return error('Le partage global est désactivé : utilise les espaces et invitations.')
if new_parent and (new_parent.pk==item.pk or item.pk in [n.pk for n in chain(new_parent)]):
return error('Déplacement impossible dans un sous-dossier de cet élément.')
if new_name==item.name and (new_parent.pk if new_parent else None)==item.parent_id:
return Response(item_info(item,request.user))
try:
relocate_item(item,new_parent,new_name)
except (NameConflict,FileExistsError) as exc:
return error(str(exc),409)
except (OSError,ValueError,ValidationError) as exc:
return error(f'Déplacement impossible : {exc}',409)
return Response(item_info(item,request.user))
@api_view(['POST'])
def trash_item(request,item_id):
@ -130,7 +148,10 @@ def restore_item(request,item_id):
if not writable(item,request.user):return error('Accès refusé.',403)
if not item.trashed_at:return error('Cet élément n’est pas dans la corbeille.')
if item.parent and any(n.trashed_at for n in chain(item.parent)):
item.parent=None
try:
relocate_item(item,None,item.name)
except (NameConflict,FileExistsError,OSError,ValueError,ValidationError) as exc:
return error(f'Restauration impossible : {exc}',409)
item.trashed_at=None;item.save()
return Response(item_info(item,request.user))
@ -141,6 +162,7 @@ def delete_item(request,item_id):
if not writable(item,request.user):return error('Accès refusé.',403)
if not item.trashed_at:return error('Déplace d’abord cet élément dans la corbeille.')
files=descendant_files(item)
folder_rel=file_location(item.space,item.parent,item.name) if item.is_folder else None
with transaction.atomic():
item.delete()
def remove_files():
@ -148,5 +170,15 @@ def delete_item(request,item_id):
for key in files:
path=(root/key).resolve()
if path.is_relative_to(root):path.unlink(missing_ok=True)
if folder_rel:
# Remove only empty folders. Untracked data is never deleted.
base=disk_path(folder_rel)
if base.is_dir():
for candidate in sorted(base.rglob('*'),key=lambda p:len(p.parts),reverse=True):
if candidate.is_dir() and not candidate.is_symlink():
try:candidate.rmdir()
except OSError:pass
try:base.rmdir()
except OSError:pass
transaction.on_commit(remove_files)
return Response(status=204)

View File

@ -1,6 +1,5 @@
"""Regular and resumable upload endpoints (disk-backed)."""
import mimetypes
import os
import uuid
from pathlib import Path
from django.conf import settings
@ -11,7 +10,8 @@ from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from .models import DriveItem, UploadSession
from .services import error, item_info, target_parent, valid_name, readable, writable
from .spaces import resolve_space, can_write_space, new_storage_location
from .spaces import resolve_space, can_write_space
from .storage_layout import file_location, disk_path, assert_available, prepare_parent, NameConflict
from .quotas import QuotaExceeded, locked_quota_tree, assert_space_capacity
@ -39,13 +39,13 @@ def upload(request):
for f in incoming:
name=valid_name(f.name)
key=str(uuid.uuid4())
location=new_storage_location(space,key)
dst=Path(settings.DRIVE_STORAGE_ROOT)/location
dst.parent.mkdir(parents=True,exist_ok=True)
location=file_location(space,parent,name)
dst=assert_available(space,parent,name)
prepare_parent(space,parent)
with open(dst,'xb') as out:
saved.append(dst) # Cleanup even when a stream fails halfway.
for block in f.chunks(chunk_size=2*1024*1024):
out.write(block)
saved.append(dst)
mime=mimetypes.guess_type(name)[0] or 'application/octet-stream'
node=DriveItem.objects.create(owner=request.user,space=space,parent=parent,name=name,
storage_key=key,storage_location=location,size=f.size,mime_type=mime)
@ -54,7 +54,7 @@ def upload(request):
for entry in saved:
path=entry[0] if isinstance(entry,tuple) else entry
path.unlink(missing_ok=True)
return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(f'Import impossible : {ex}')
return error(str(ex),413) if isinstance(ex,QuotaExceeded) else error(str(ex),409) if isinstance(ex,(NameConflict,FileExistsError)) else error(f'Import impossible : {ex}')
except Exception:
for entry in saved:
path=entry[0] if isinstance(entry,tuple) else entry
@ -74,6 +74,9 @@ def upload_start(request):
except (TypeError,ValueError) as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
if size<0 or size>settings.DRIVE_MAX_FILE_SIZE:return error('Taille de fichier non autorisée.',413)
try:
assert_available(space,parent,name)
except NameConflict as ex: return error(str(ex),409)
with transaction.atomic():
with locked_quota_tree(space):
if UploadSession.objects.filter(owner=request.user).count()>=20:
@ -131,17 +134,31 @@ def upload_finish(request,upload_id):
if not can_write_space(request.user,job.space) or (job.parent and (not readable(job.parent,request.user) or not writable(job.parent,request.user))):
return error('Dossier de destination indisponible.',403)
key=str(uuid.uuid4())
location=new_storage_location(job.space,key)
dst=Path(settings.DRIVE_STORAGE_ROOT)/location
dst.parent.mkdir(parents=True,exist_ok=True)
os.replace(src,dst)
location=file_location(job.space,job.parent,job.name)
created=False
try:
dst=assert_available(job.space,job.parent,job.name)
prepare_parent(job.space,job.parent)
# Exclusive creation: never overwrite a file already on disk.
# Copy, rather than rename: the temporary directory can be another volume.
with src.open('rb') as source, dst.open('xb') as output:
created=True
import shutil
shutil.copyfileobj(source,output,2*1024*1024)
except (NameConflict,FileExistsError) as exc:
if created:dst.unlink(missing_ok=True)
return error(str(exc),409)
except OSError as exc:
if created:dst.unlink(missing_ok=True)
return error(f'Finalisation impossible : {exc}')
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
try:
node=DriveItem.objects.create(owner=request.user,space=job.space,parent=job.parent,name=job.name,
size=job.total_size,mime_type=mime,storage_key=key,storage_location=location)
job.delete()
transaction.on_commit(lambda: src.unlink(missing_ok=True))
except Exception:
os.replace(dst,src)
dst.unlink(missing_ok=True)
raise
return Response(item_info(node,request.user),status=201)