144 lines
6.5 KiB
Python
144 lines
6.5 KiB
Python
"""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)
|