2026-10-13 19:43:55 +02:00

415 lines
18 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.

import mimetypes
import os
import shutil
import uuid
from pathlib import Path
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.db import transaction
from django.db.models import Q, Sum, Count
from django.http import FileResponse, Http404
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, parser_classes
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from .models import DriveItem
def error(message, code=400):
return Response({'detail':message}, status=code)
def item_info(item):
return {
'id':str(item.id), 'name':item.name, 'is_folder':item.is_folder,
'size':item.size,'mime_type':item.mime_type,'owner_id':item.owner_id,
'owner':item.owner.username,'parent':str(item.parent_id) if item.parent_id else None,
'is_shared':item.is_shared,'is_starred':item.is_starred,
'trashed_at':item.trashed_at.isoformat() if item.trashed_at else None,
'created_at':item.created_at.isoformat(),'updated_at':item.updated_at.isoformat(),
}
def get_item(pk):
try:
return DriveItem.objects.select_related('owner','parent').get(pk=pk)
except (DriveItem.DoesNotExist,ValueError,ValidationError):
raise Http404
def chain(item):
ancestors=[]
visited=set()
while item is not None:
if item.pk in visited:
raise Http404
visited.add(item.pk)
ancestors.append(item)
item=item.parent
return ancestors
def readable(item, user, allow_trashed=False):
ancestors=chain(item)
if not allow_trashed and any(n.trashed_at is not None for n in ancestors):
return False
if user.is_staff or user.is_superuser or item.owner_id == user.pk:
return True
return bool(ancestors[-1].is_shared and ancestors[-1].owner_id == item.owner_id)
def writable(item,user):
return user.is_staff or user.is_superuser or item.owner_id == user.pk
def valid_name(name):
if not isinstance(name,str) or not name.strip() or len(name)>255:
raise ValueError('Nom invalide (1 à 255 caractères).')
name=name.strip()
if name in ('.','..') or any(c in name for c in ('/','\\','\x00','\r','\n')):
raise ValueError('Ce nom contient des caractères interdits.')
return name.strip()
def target_parent(parent_id, user, owner_id=None):
if not parent_id:
return None
try:
parent=DriveItem.objects.select_related('parent').get(pk=uuid.UUID(str(parent_id)))
except (ValueError,DriveItem.DoesNotExist,AttributeError):
raise ValueError('Dossier de destination introuvable.')
if not parent.is_folder or not readable(parent,user) or not writable(parent,user):
raise PermissionError('Dossier de destination inaccessible en écriture.')
if owner_id is not None and parent.owner_id != owner_id:
raise PermissionError('Le propriétaire doit être identique.')
return parent
@api_view(['GET'])
def me(request):
return Response({'id':request.user.pk,'username':request.user.username,
'is_admin':request.user.is_staff or request.user.is_superuser,
'first_name':request.user.first_name})
@api_view(['GET'])
def listing(request):
user=request.user
view=request.query_params.get('view','files')
q=request.query_params.get('q','').strip()
parent_id=request.query_params.get('parent')
parent=None
if parent_id:
parent=get_item(parent_id)
if not parent.is_folder or not readable(parent,user):
return error('Dossier inaccessible.',403)
if view=='trash':
nodes=DriveItem.objects.filter(owner=user,trashed_at__isnull=False).select_related('owner')
elif view=='shared':
nodes=DriveItem.objects.filter(parent__isnull=True,is_shared=True,trashed_at__isnull=True).select_related('owner')
if not user.is_staff:
nodes=nodes.exclude(owner=user)
elif view=='starred':
nodes=DriveItem.objects.filter(owner=user,is_starred=True,trashed_at__isnull=True).select_related('owner','parent')
elif view=='recent':
nodes=DriveItem.objects.filter(owner=user,is_folder=False,trashed_at__isnull=True).select_related('owner','parent').order_by('-updated_at')
elif q:
nodes=DriveItem.objects.filter(owner=user,trashed_at__isnull=True,name__icontains=q).select_related('owner','parent')
elif parent:
nodes=DriveItem.objects.filter(parent=parent,trashed_at__isnull=True).select_related('owner','parent')
else:
nodes=DriveItem.objects.filter(parent__isnull=True,trashed_at__isnull=True).filter(
Q(owner=user)|Q(is_shared=True)).select_related('owner','parent')
items=[]
for n in nodes[:500]:
if view=='trash' or readable(n,user):
items.append(item_info(n))
trail=[]
if parent:
for n in reversed(chain(parent)):
if readable(n,user):
trail.append({'id':str(n.id),'name':n.name})
return Response({'items':items,'breadcrumb':trail,'limited':len(items)>=500})
@api_view(['GET'])
def stats(request):
user=request.user
result=DriveItem.objects.filter(owner=user,trashed_at__isnull=True,is_folder=False).aggregate(
count=Count('id'),total=Sum('size'))
return Response({'files':result['count'] or 0,'bytes':result['total'] or 0,
'folders':DriveItem.objects.filter(owner=user,is_folder=True,trashed_at__isnull=True).count(),
'shared':DriveItem.objects.filter(owner=user,parent__isnull=True,is_shared=True,trashed_at__isnull=True).count()})
@api_view(['POST'])
def create_folder(request):
try:
name=valid_name(request.data.get('name'))
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)
folder=DriveItem(owner=request.user,parent=parent,name=name,is_folder=True)
folder.full_clean()
folder.save()
return Response(item_info(folder),status=201)
@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)
@api_view(['PATCH'])
def update_item(request,item_id):
item=get_item(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_starred' in data:item.is_starred=bool(data['is_starred'])
if 'is_shared' in data:
if item.parent_id:return error('Seuls les éléments à la racine peuvent être partagés.')
item.is_shared=bool(data['is_shared'])
if 'parent' in data:
try:
new_parent=target_parent(data['parent'],request.user,item.owner_id)
except ValueError as ex:return error(str(ex))
except PermissionError as ex:return error(str(ex),403)
if new_parent:
if 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
if new_parent:item.is_shared=False
try:item.full_clean()
except ValidationError as ex:return error(str(ex))
item.save()
return Response(item_info(item))
@api_view(['POST'])
def trash_item(request,item_id):
item=get_item(item_id)
if not readable(item,request.user) or not writable(item,request.user):return error('Accès refusé.',403)
if item.trashed_at:return error('Élément déjà dans la corbeille.')
item.trashed_at=timezone.now();item.save(update_fields=['trashed_at','updated_at'])
return Response({'ok':True})
@api_view(['POST'])
def restore_item(request,item_id):
item=get_item(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.')
# An item whose parent was separately trashed is restored to root.
if item.parent and any(n.trashed_at for n in chain(item.parent)):
item.parent=None
item.trashed_at=None;item.save()
return Response(item_info(item))
def descendant_files(node):
# Collect all physical paths before cascade delete.
files=[]
pending=[node.pk]
while pending:
batch=pending[:500];pending=pending[500:]
children=list(DriveItem.objects.filter(parent_id__in=batch).values_list('pk','is_folder','storage_key'))
pending.extend(row[0] for row in children)
for _,is_folder,key in children:
if not is_folder and key: files.append(key)
if not node.is_folder and node.storage_key:files.append(node.storage_key)
return files
@api_view(['DELETE'])
def delete_item(request,item_id):
item=get_item(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)
with transaction.atomic():
item.delete()
def remove_files():
for key in files:
path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
path.unlink(missing_ok=True)
transaction.on_commit(remove_files)
return Response(status=204)
@api_view(['POST'])
def content_url(request,item_id):
item=get_item(item_id)
if item.is_folder or not readable(item,request.user):return error('Fichier inaccessible.',403)
inline=bool(request.data.get('inline',False))
safe_types={'image/jpeg','image/png','image/gif','image/webp','application/pdf',
'audio/mpeg','audio/ogg','audio/wav','audio/mp4'}
if inline and item.mime_type not in safe_types:
return error('Aperçu non disponible pour ce type de fichier.')
token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{1 if inline else 0}')
return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}',
'expires_in':300})
@api_view(['GET'])
@permission_classes([AllowAny])
def content(request,item_id):
# A short-lived signed URL permits native streaming/inline display without storing a JWT in the URL.
token=request.query_params.get('token','')
try:
value=TimestampSigner(salt='drive-content').unsign(token,max_age=300)
expected,inline=value.rsplit(':',1)
if expected!=str(item_id) or inline not in ('0','1'):raise BadSignature()
except (BadSignature,SignatureExpired,ValueError):
raise Http404
item=get_item(item_id)
if item.is_folder or item.trashed_at:raise Http404
if any(n.trashed_at for n in chain(item)):raise Http404
# Authorization performed when URL was minted, 5-minute token is a bearer credential.
key=item.storage_key
if not key or len(key)!=36:
raise Http404
path=Path(settings.DRIVE_STORAGE_ROOT)/key[:2]/key
if not path.is_file():raise Http404
response=FileResponse(open(path,'rb'),as_attachment=inline=='0',filename=item.name,
content_type=item.mime_type if inline=='1' else 'application/octet-stream')
response['Cache-Control']='private, no-store'
response['X-Content-Type-Options']='nosniff'
return response
# Large uploads are chunked in 8 MiB pieces; a refresh does not restart the upload.
@api_view(['POST'])
def upload_start(request):
from .models import UploadSession
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):
from .models import UploadSession
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):
from .models import UploadSession
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):
from .models import UploadSession
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):
from .models import UploadSession
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)