GooDrive/backend/drive/views_transfers.py
Louis Mazin 97a25e4df8 nice
2026-10-13 21:15:53 +02:00

144 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Regular and resumable upload endpoints (disk-backed)."""
import mimetypes
import os
import uuid
from pathlib import Path
from django.conf import settings
from django.db import transaction
from django.http import Http404
from rest_framework.decorators import api_view, parser_classes
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
@api_view(['POST'])
@parser_classes([MultiPartParser,FormParser])
def upload(request):
try:
parent=target_parent(request.data.get('parent'),request.user,request.user.pk)
except ValueError as ex: return error(str(ex))
except PermissionError as ex: return error(str(ex),403)
incoming=request.FILES.getlist('files')
if not incoming:
return error('Aucun fichier envoyé.')
# Uploads are streamed to a disk temp directory (not buffered in RAM).
if any(f.size>settings.DRIVE_MAX_FILE_SIZE for f in incoming):
return error('Un fichier dépasse la taille maximum configurée.',413)
saved=[]
try:
with transaction.atomic():
for f in incoming:
name=valid_name(f.name)
key=str(uuid.uuid4())
dst=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
dst.parent.mkdir(parents=True,exist_ok=True)
with open(dst,'xb') as out:
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,parent=parent,name=name,
storage_key=key,size=f.size,mime_type=mime)
# Keep a response prepared even if the transaction is rolled back.
saved[-1] = (dst,node)
except (ValueError,OSError) as ex:
for entry in saved:
path=entry[0] if isinstance(entry,tuple) else entry
path.unlink(missing_ok=True)
return error(f'Import impossible : {ex}')
except Exception:
for entry in saved:
path=entry[0] if isinstance(entry,tuple) else entry
path.unlink(missing_ok=True)
raise
return Response({'items':[item_info(n) for _,n in saved]},status=201)
# Large uploads are chunked in 8 MiB pieces; a refresh does not restart the upload.
@api_view(['POST'])
def upload_start(request):
try:
name=valid_name(request.data.get('name'))
size=int(request.data.get('size',-1))
parent=target_parent(request.data.get('parent'),request.user,request.user.pk)
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)
if UploadSession.objects.filter(owner=request.user).count()>=20:
return error('Trop d’imports en cours (20 maximum).',429)
job=UploadSession.objects.create(owner=request.user,parent=parent,name=name,total_size=size)
return Response({'id':str(job.pk),'offset':job.received},status=201)
@api_view(['GET'])
def upload_status(request,upload_id):
try:job=UploadSession.objects.get(pk=upload_id,owner=request.user)
except UploadSession.DoesNotExist:raise Http404
return Response({'id':str(job.pk),'offset':job.received,'size':job.total_size})
@api_view(['POST'])
@parser_classes([MultiPartParser,FormParser])
def upload_chunk(request,upload_id):
part=request.FILES.get('part')
if not part or part.size>8*1024*1024:return error('Bloc manquant ou supérieur à 8 Mio.',413)
try:offset=int(request.data.get('offset','-1'))
except ValueError:return error('Position de bloc invalide.')
# Row lock serializes concurrent chunks and prevents repeated chunks corrupting the file.
with transaction.atomic():
try:job=UploadSession.objects.select_for_update().get(pk=upload_id,owner=request.user)
except UploadSession.DoesNotExist:raise Http404
if offset!=job.received:
return Response({'detail':'Décalage de reprise.','offset':job.received},status=409)
if offset+part.size>job.total_size:return error('Bloc plus grand que la taille totale.')
path=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part'
path.parent.mkdir(parents=True,exist_ok=True)
with open(path,'a+b') as out:
# A previous aborted request can leave an uncommitted partial block.
out.truncate(job.received)
out.seek(job.received)
for block in part.chunks(chunk_size=1024*1024):out.write(block)
job.received+=part.size
job.save(update_fields=['received','updated_at'])
return Response({'offset':job.received})
@api_view(['POST'])
def upload_finish(request,upload_id):
with transaction.atomic():
try:job=UploadSession.objects.select_for_update().get(pk=upload_id,owner=request.user)
except UploadSession.DoesNotExist:raise Http404
src=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part'
if job.received!=job.total_size:return error('Le fichier n’est pas entièrement transmis.')
if job.total_size==0:
src.touch(exist_ok=True)
if not src.exists() or src.stat().st_size!=job.total_size:
return error('Le fichier temporaire est incomplet.')
if 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())
dst=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
dst.parent.mkdir(parents=True,exist_ok=True)
os.replace(src,dst)
mime=mimetypes.guess_type(job.name)[0] or 'application/octet-stream'
try:
node=DriveItem.objects.create(owner=request.user,parent=job.parent,name=job.name,
size=job.total_size,mime_type=mime,storage_key=key)
job.delete()
except Exception:
os.replace(dst,src)
raise
return Response(item_info(node),status=201)
@api_view(['DELETE'])
def upload_cancel(request,upload_id):
try:job=UploadSession.objects.get(pk=upload_id,owner=request.user)
except UploadSession.DoesNotExist:raise Http404
path=Path(settings.FILE_UPLOAD_TEMP_DIR)/f'drive-{job.pk}.part'
job.delete()
path.unlink(missing_ok=True)
return Response(status=204)