GooDrive/backend/drive/views_content.py
Louis Mazin 90004d4540 nickel
2026-10-14 17:27:37 +02:00

107 lines
5.0 KiB
Python

"""Short-lived, permission-bound preview/download URLs and HTTP byte ranges."""
import mimetypes
import re
from django.contrib.auth import get_user_model
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
from django.http import FileResponse, Http404, StreamingHttpResponse, HttpResponse
from django.views.decorators.clickjacking import xframe_options_sameorigin
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from .services import error, get_item, readable
from .spaces import storage_path
from .permissions import item_rights
IMAGE_TYPES = {'image/jpeg','image/png','image/gif','image/webp','image/avif','image/bmp'}
VIDEO_TYPES = {'video/mp4','video/webm','video/ogg','video/quicktime','video/x-m4v'}
AUDIO_TYPES = {'audio/mpeg','audio/ogg','audio/wav','audio/mp4','audio/webm','audio/flac','audio/aac','audio/x-wav'}
TEXT_TYPES = {'text/plain','text/csv','text/markdown','text/x-python','text/javascript','text/css',
'application/json','application/xml','text/xml'}
def safe_preview_type(item):
mime = (mimetypes.guess_type(item.name)[0] or item.mime_type or '').lower()
if mime in IMAGE_TYPES | VIDEO_TYPES | AUDIO_TYPES | {'application/pdf'}:
return mime
if mime in TEXT_TYPES or mime in {'text/html','image/svg+xml'} or mime.startswith('text/'):
return 'text/plain; charset=utf-8' # Never render user HTML/scripts inline.
return None
@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)
if not request.data.get('inline',False) and 'download' not in item_rights(request.user,item):
return error('Téléchargement non autorisé.',403)
inline=bool(request.data.get('inline',False))
if inline and not safe_preview_type(item):return error('Aperçu non disponible pour ce type de fichier.',415)
# Encode the authorized identity so access is checked again on every request.
token=TimestampSigner(salt='drive-content').sign(f'{item.pk}:{int(inline)}:{request.user.pk}')
return Response({'url':f'/api/drive/items/{item.pk}/content/?token={token}','expires_in':7200})
def read_bytes(path, start, end):
with path.open('rb') as source:
source.seek(start)
remaining=end-start+1
while remaining>0:
chunk=source.read(min(1024*1024,remaining))
if not chunk:break
remaining-=len(chunk)
yield chunk
@api_view(['GET','HEAD'])
@permission_classes([AllowAny])
@xframe_options_sameorigin
def content(request,item_id):
token=request.query_params.get('token','')
try:
value=TimestampSigner(salt='drive-content').unsign(token,max_age=7200)
expected,inline,user_pk=value.rsplit(':',2)
if expected!=str(item_id) or inline not in ('0','1'):raise BadSignature()
user=get_user_model().objects.get(pk=int(user_pk),is_active=True)
except (BadSignature,SignatureExpired,ValueError,TypeError,get_user_model().DoesNotExist):
raise Http404
item=get_item(item_id)
if item.is_folder or not readable(item,user) or (inline=='0' and 'download' not in item_rights(user,item)):raise Http404
media=safe_preview_type(item)
if inline=='1' and not media:raise Http404
path=storage_path(item)
if not path.is_file():raise Http404
size=path.stat().st_size
content_type=media if inline=='1' else 'application/octet-stream'
name=item.name
byte_range=request.headers.get('Range')
# Native video/audio controls commonly request Range: bytes=0-.
if byte_range:
match=re.fullmatch(r'bytes=(\d*)-(\d*)',byte_range.strip())
if not match or (not match[1] and not match[2]):
response=HttpResponse(status=416)
response['Content-Range']=f'bytes */{size}'
return response
if match[1]:
start=int(match[1]);end=int(match[2]) if match[2] else size-1
else:
suffix=int(match[2]);start=max(0,size-suffix);end=size-1
end=min(end,size-1)
if start>=size or end<start:
response=HttpResponse(status=416)
response['Content-Range']=f'bytes */{size}'
return response
response=StreamingHttpResponse(read_bytes(path,start,end) if request.method=='GET' else [],status=206,content_type=content_type)
response['Content-Range']=f'bytes {start}-{end}/{size}'
response['Content-Length']=end-start+1
elif request.method=='HEAD':
response=HttpResponse(content_type=content_type)
response['Content-Length']=size
else:
response=FileResponse(path.open('rb'),as_attachment=inline=='0',filename=name,content_type=content_type)
response['Accept-Ranges']='bytes'
response['Cache-Control']='private, no-store'
response['X-Content-Type-Options']='nosniff'
if content_type.startswith('text/plain'):
response['Content-Security-Policy']="sandbox"
return response