415 lines
18 KiB
Python
415 lines
18 KiB
Python
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)
|