"""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)