test
This commit is contained in:
parent
8034138cc7
commit
6e1ca588b7
@ -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.')
|
||||
with transaction.atomic():
|
||||
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)
|
||||
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'])
|
||||
(Path(settings.DRIVE_STORAGE_ROOT) / obj.relative_path).mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
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()
|
||||
|
||||
114
backend/drive/storage_layout.py
Normal file
114
backend/drive/storage_layout.py
Normal 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
|
||||
@ -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)
|
||||
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)]):
|
||||
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.')
|
||||
item.parent=new_parent
|
||||
try:item.full_clean()
|
||||
except ValidationError as ex:return error(str(ex))
|
||||
item.save()
|
||||
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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user