107 lines
5.0 KiB
Python
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
|